@meyicloud/meyi-cost-server 1.4.1

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.
Files changed (38) hide show
  1. package/AGENTS.md +197 -0
  2. package/README.md +454 -0
  3. package/cur.js +2 -0
  4. package/index.js +2 -0
  5. package/package.json +36 -0
  6. package/src/controllers/budget.controller.js +39 -0
  7. package/src/controllers/cost-analysis.controller.js +28 -0
  8. package/src/controllers/cost.controller.js +144 -0
  9. package/src/cur-discovery/cur-discovery.aws.js +82 -0
  10. package/src/cur-discovery/cur-discovery.repository.js +177 -0
  11. package/src/cur-discovery/cur-discovery.service.js +112 -0
  12. package/src/cur-discovery/cur-discovery.worker.js +57 -0
  13. package/src/cur-discovery/schema.js +48 -0
  14. package/src/lib/cost-analysis.js +116 -0
  15. package/src/lib/cost-utils.js +98 -0
  16. package/src/lib/llm-provider.js +239 -0
  17. package/src/models/budget.model.js +26 -0
  18. package/src/models/cur-data-status.model.js +17 -0
  19. package/src/models/cur-ingestion.model.js +9 -0
  20. package/src/models/customer-aws-context.model.js +15 -0
  21. package/src/models/saas-cur-context.model.js +10 -0
  22. package/src/plugin.js +82 -0
  23. package/src/repositories/aws-onboarding.repository.js +134 -0
  24. package/src/repositories/budget-alert.repository.js +37 -0
  25. package/src/repositories/budget.repository.js +33 -0
  26. package/src/repositories/cost-analysis.repository.js +50 -0
  27. package/src/routes/index.js +31 -0
  28. package/src/schema/cost-analysis.schema.js +7 -0
  29. package/src/schema/cost-budget.schema.js +9 -0
  30. package/src/services/aws-context.service.js +1 -0
  31. package/src/services/budget-alert.service.js +47 -0
  32. package/src/services/budget.service.js +32 -0
  33. package/src/services/cost-analysis-data.service.js +39 -0
  34. package/src/services/cost-analysis.service.js +190 -0
  35. package/src/services/cur-provider.service.js +95 -0
  36. package/src/services/cur.service.js +354 -0
  37. package/src/services/customer-aws-context.service.js +26 -0
  38. package/src/services/saas-athena-context.service.js +45 -0
@@ -0,0 +1,95 @@
1
+ import { CurDataStatus } from "../models/cur-data-status.model.js";
2
+ import { getCurDataStatus } from "./cur.service.js";
3
+
4
+ export class CurProviderService {
5
+ constructor({ athenaContextService, logger = console, env = process.env } = {}) {
6
+ this.athenaContextService = athenaContextService;
7
+ this.logger = logger;
8
+ this.statusCache = new Map();
9
+ this.statusCacheMs = Math.max(Number(env.COST_CUR_STATUS_CACHE_MS || 300000), 0);
10
+ }
11
+
12
+ async run(customerContext, action) {
13
+ const context = this.athenaContextService.resolve(customerContext);
14
+ const readiness = await this.status(customerContext);
15
+ if (!readiness.ready) throw this.notReadyError(readiness);
16
+ try {
17
+ return await action(context.client, context.config);
18
+ } catch (error) {
19
+ this.logger.error?.("[Cost] CUR/Athena query failed", error);
20
+ const unavailable = new Error("CUR is configured, but its Athena data could not be queried. Review the CUR discovery status, Glue table, Athena output location, and IAM permissions.");
21
+ unavailable.name = "CurQueryUnavailableError";
22
+ unavailable.statusCode = 503;
23
+ unavailable.state = "unavailable";
24
+ unavailable.action = "review_cur_discovery";
25
+ unavailable.cause = error;
26
+ throw unavailable;
27
+ }
28
+ }
29
+
30
+ notReadyError(readiness) {
31
+ const error = new Error(readiness.message || "CUR data is not ready for this tenant.");
32
+ error.name = readiness.state === "not_configured" ? "CurNotConfiguredError" : "CurDataNotReadyError";
33
+ error.statusCode = 503;
34
+ error.state = readiness.state;
35
+ error.action = readiness.action;
36
+ error.dataStatus = readiness;
37
+ return error;
38
+ }
39
+
40
+ async status(customerContext) {
41
+ const context = this.athenaContextService.resolve(customerContext);
42
+ if (!context.config.enabled) {
43
+ const discovery = customerContext.meta?.curDiscovery || null;
44
+ const sourceConfigured = context.ingestion.sourceConfigured;
45
+ return new CurDataStatus({
46
+ configured: false,
47
+ required: true,
48
+ credentialMode: context.credentialMode,
49
+ ingestionMode: context.ingestion.mode,
50
+ sourceConfigured,
51
+ state: discovery?.status ? String(discovery.status).toLowerCase() : "not_configured",
52
+ action: discovery || sourceConfigured ? "review_cur_discovery" : "connect_source",
53
+ discovery,
54
+ message: context.config.disabledReason || (discovery || sourceConfigured
55
+ ? "CUR discovery has not produced a queryable Glue table yet. Review its status and resolve any reported error."
56
+ : "CUR is not configured for this tenant. Connect an AWS cost source and deploy the CUR setup."),
57
+ });
58
+ }
59
+ const cacheKey = `${customerContext.tenant}:${context.config.tenantPartition}`;
60
+ const cached = this.statusCache.get(cacheKey);
61
+ if (cached && Date.now() - cached.createdAt < this.statusCacheMs) return cached.status;
62
+ try {
63
+ const result = await getCurDataStatus({ client: context.client, config: context.config, tenant: customerContext.tenant });
64
+ const status = new CurDataStatus({
65
+ configured: true,
66
+ required: true,
67
+ ready: result.recordCount > 0,
68
+ state: result.recordCount > 0 ? "ready" : "pending_data",
69
+ action: result.recordCount > 0 ? null : "review_cur_discovery",
70
+ credentialMode: context.credentialMode,
71
+ ingestionMode: context.ingestion.mode,
72
+ sourceConfigured: context.ingestion.sourceConfigured,
73
+ lastDataAt: result.lastDataAt,
74
+ recordCount: result.recordCount,
75
+ message: result.recordCount > 0 ? null : "CUR is configured, but no report rows are available for this tenant yet. Verify that AWS delivered report files and that discovery completed for the tenant partition.",
76
+ discovery: customerContext.meta?.curDiscovery || null,
77
+ });
78
+ this.statusCache.set(cacheKey, { createdAt: Date.now(), status });
79
+ return status;
80
+ } catch (error) {
81
+ this.logger.warn?.("[Cost] CUR readiness check failed", error.message);
82
+ return new CurDataStatus({
83
+ configured: true,
84
+ required: true,
85
+ state: "unavailable",
86
+ action: "review_cur_discovery",
87
+ credentialMode: context.credentialMode,
88
+ ingestionMode: context.ingestion.mode,
89
+ sourceConfigured: context.ingestion.sourceConfigured,
90
+ message: "CUR is configured, but its Athena data is unavailable. Review the discovery error, Glue table, Athena output location, and IAM permissions.",
91
+ discovery: customerContext.meta?.curDiscovery || null,
92
+ });
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,354 @@
1
+ import {
2
+ AthenaClient,
3
+ GetQueryExecutionCommand,
4
+ GetQueryResultsCommand,
5
+ StartQueryExecutionCommand,
6
+ } from "@aws-sdk/client-athena";
7
+
8
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
9
+ const round = (value) => Math.round((Number(value) + Number.EPSILON) * 100) / 100;
10
+ const amount = (value) => Number(value || 0);
11
+ const sqlString = (value) => `'${String(value).replaceAll("'", "''")}'`;
12
+
13
+ function identifier(value, label, allowHyphen = false) {
14
+ const pattern = allowHyphen ? /^[a-zA-Z0-9_-]+$/ : /^[a-zA-Z_][a-zA-Z0-9_]*$/;
15
+ if (!pattern.test(String(value || ""))) throw new Error(`Invalid CUR ${label}`);
16
+ return String(value);
17
+ }
18
+
19
+ export function getCurConfig(env = process.env, metadata = {}) {
20
+ const database = String(metadata.curDatabase || env.COST_CUR_DATABASE || "").trim();
21
+ const outputLocation = String(metadata.curOutputLocation || env.COST_CUR_OUTPUT_LOCATION || "").trim();
22
+ const tableName = String(env.COST_CUR_TABLE || metadata.curTable || "").trim();
23
+ return {
24
+ enabled: Boolean(database && tableName && outputLocation),
25
+ required: true,
26
+ database: database ? identifier(database, "database", true) : "",
27
+ table: tableName ? identifier(tableName, "table", true) : "",
28
+ outputLocation,
29
+ region: String(metadata.curRegion || env.COST_CUR_REGION || env.AWS_REGION || "us-east-1"),
30
+ workgroup: identifier(metadata.curWorkgroup || env.COST_CUR_WORKGROUP || "meyi-cost", "workgroup", true),
31
+ tenantColumn: identifier(metadata.curTenantColumn || env.COST_CUR_TENANT_COLUMN || "tenant_id", "tenant column"),
32
+ tenantPartition: String(metadata.curTenantPartition || env.COST_CUR_TENANT_PARTITION || "").trim(),
33
+ maxRows: Math.min(Math.max(Number(env.COST_CUR_MAX_ROWS || 1000), 1), 5000),
34
+ };
35
+ }
36
+
37
+ export const createCurClient = ({ config, credentials }) => new AthenaClient({
38
+ region: config.region,
39
+ credentials,
40
+ });
41
+
42
+ async function execute(client, config, query) {
43
+ const started = await client.send(new StartQueryExecutionCommand({
44
+ QueryString: query,
45
+ QueryExecutionContext: { Database: config.database },
46
+ ResultConfiguration: { OutputLocation: config.outputLocation },
47
+ WorkGroup: config.workgroup,
48
+ }));
49
+ const id = started.QueryExecutionId;
50
+ if (!id) throw new Error("Athena did not return a query execution ID");
51
+
52
+ let delay = 300;
53
+ const deadline = Date.now() + 90_000;
54
+ while (Date.now() < deadline) {
55
+ const execution = await client.send(new GetQueryExecutionCommand({ QueryExecutionId: id }));
56
+ const state = execution.QueryExecution?.Status?.State;
57
+ if (state === "SUCCEEDED") break;
58
+ if (state === "FAILED" || state === "CANCELLED") {
59
+ const error = new Error(execution.QueryExecution?.Status?.StateChangeReason || `Athena query ${state.toLowerCase()}`);
60
+ error.name = "AthenaQueryError";
61
+ throw error;
62
+ }
63
+ await sleep(delay);
64
+ delay = Math.min(delay * 1.5, 2_000);
65
+ }
66
+ if (Date.now() >= deadline) throw new Error("Athena query timed out after 90 seconds");
67
+
68
+ const rawRows = [];
69
+ let token;
70
+ do {
71
+ const page = await client.send(new GetQueryResultsCommand({ QueryExecutionId: id, NextToken: token, MaxResults: 1000 }));
72
+ rawRows.push(...(page.ResultSet?.Rows || []));
73
+ token = page.NextToken;
74
+ } while (token);
75
+
76
+ if (!rawRows.length) return [];
77
+ const headers = (rawRows[0].Data || []).map((item) => item.VarCharValue || "");
78
+ return rawRows.slice(1).map((row) => Object.fromEntries(headers.map((header, index) => [header, row.Data?.[index]?.VarCharValue ?? null])));
79
+ }
80
+
81
+ function table(config) {
82
+ return `"${config.database}"."${config.table}"`;
83
+ }
84
+
85
+ function tenantWhere(config, tenant) {
86
+ const partition = config.tenantPartition || tenant;
87
+ return `"${config.tenantColumn}" = ${sqlString(partition)}`;
88
+ }
89
+
90
+ export async function getCurDataStatus({ client, config, tenant }) {
91
+ const statusRows = await execute(client, config, `
92
+ SELECT
93
+ CAST(COUNT(*) AS BIGINT) AS record_count,
94
+ CAST(MAX(CAST(line_item_usage_start_date AS TIMESTAMP)) AS VARCHAR) AS last_data_at
95
+ FROM ${table(config)}
96
+ WHERE ${tenantWhere(config, tenant)}
97
+ `);
98
+ const status = statusRows[0] || {};
99
+ return {
100
+ recordCount: Number(status.record_count || 0),
101
+ lastDataAt: status.last_data_at || null,
102
+ };
103
+ }
104
+
105
+ function rangeWhere(range) {
106
+ return `CAST(line_item_usage_start_date AS TIMESTAMP) >= TIMESTAMP ${sqlString(`${range.Start} 00:00:00`)}
107
+ AND CAST(line_item_usage_start_date AS TIMESTAMP) < TIMESTAMP ${sqlString(`${range.End} 00:00:00`)}`;
108
+ }
109
+
110
+ function inCondition(column, values = []) {
111
+ if (!values.length) return "";
112
+ return `AND CAST("${column}" AS VARCHAR) IN (${values.map(sqlString).join(", ")})`;
113
+ }
114
+
115
+ function cur2TagExpression(tagKey) {
116
+ const key = String(tagKey || "Name").trim();
117
+ if (!key) throw new Error("Invalid CUR tag key");
118
+ return `COALESCE(element_at(resource_tags, ${sqlString(`user:${key.replace(/^user:/, "")}`)}), element_at(resource_tags, ${sqlString(key.replace(/^user:/, ""))}))`;
119
+ }
120
+
121
+ function inExpression(expression, values = []) {
122
+ if (!values.length) return "";
123
+ return `AND CAST(${expression} AS VARCHAR) IN (${values.map(sqlString).join(", ")})`;
124
+ }
125
+
126
+ function filterWhere(filters = {}) {
127
+ const conditions = [
128
+ inCondition("product_product_name", filters.services),
129
+ inCondition("product_region_code", filters.regions),
130
+ inCondition("line_item_usage_account_id", filters.accountIds),
131
+ ];
132
+ if (filters.tagKey && filters.tagValues?.length) {
133
+ conditions.push(inExpression(cur2TagExpression(filters.tagKey), filters.tagValues));
134
+ }
135
+ return conditions.filter(Boolean).join("\n ");
136
+ }
137
+
138
+ function dimensionExpression(groupBy, tagKey) {
139
+ const dimensions = {
140
+ service: "product_product_name",
141
+ account: "line_item_usage_account_id",
142
+ region: "product_region_code",
143
+ resource: "line_item_resource_id",
144
+ };
145
+ if (groupBy === "tag") return cur2TagExpression(tagKey);
146
+ return dimensions[groupBy] ? `"${dimensions[groupBy]}"` : undefined;
147
+ }
148
+
149
+ function summarize(periods, accountNames = new Map()) {
150
+ const totals = new Map();
151
+ for (const period of periods) totals.set(period.key, (totals.get(period.key) || 0) + period.amount);
152
+ const total = [...totals.values()].reduce((sum, value) => sum + value, 0);
153
+ return [...totals.entries()]
154
+ .map(([key, value]) => ({
155
+ key,
156
+ label: accountNames.get(key) || key,
157
+ accountId: accountNames.has(key) || /^\d{12}$/.test(key) ? key : undefined,
158
+ amount: round(value),
159
+ percentage: total ? round(value / total * 100) : 0,
160
+ }))
161
+ .sort((a, b) => b.amount - a.amount);
162
+ }
163
+
164
+ export async function getCurReport({ client, config, tenant, range, groupBy, granularity = "MONTHLY", tagKey, accountNames = new Map(), filters = {}, includeBreakdown = false }) {
165
+ const column = dimensionExpression(groupBy, tagKey);
166
+ if (!column) throw new Error("Unsupported CUR report group");
167
+ const bucket = granularity === "DAILY" ? "day" : "month";
168
+ const limit = groupBy === "resource" ? `LIMIT ${config.maxRows}` : "";
169
+ const query = `
170
+ SELECT
171
+ CAST(date_trunc('${bucket}', CAST(line_item_usage_start_date AS TIMESTAMP)) AS VARCHAR) AS period_start,
172
+ COALESCE(NULLIF(CAST(${column} AS VARCHAR), ''), 'Unallocated') AS item_key,
173
+ CAST(SUM(line_item_unblended_cost) AS DOUBLE) AS amount
174
+ FROM ${table(config)}
175
+ WHERE ${tenantWhere(config, tenant)}
176
+ AND ${rangeWhere(range)}
177
+ AND line_item_unblended_cost > 0
178
+ ${filterWhere(filters)}
179
+ GROUP BY 1, 2
180
+ ORDER BY 1, 3 DESC
181
+ ${limit}
182
+ `;
183
+ const rows = await execute(client, config, query);
184
+ const periods = rows.map((row) => ({ start: String(row.period_start).slice(0, 10), key: row.item_key || "Unallocated", amount: amount(row.amount) }));
185
+ let summary = summarize(periods, accountNames);
186
+ if (includeBreakdown && groupBy !== "resource") {
187
+ const relationQuery = `
188
+ SELECT
189
+ COALESCE(NULLIF(CAST(${column} AS VARCHAR), ''), 'Unallocated') AS item_key,
190
+ COALESCE(NULLIF(CAST(line_item_usage_account_id AS VARCHAR), ''), 'Unallocated') AS account_key,
191
+ COALESCE(NULLIF(CAST(product_region_code AS VARCHAR), ''), 'GLOBAL') AS region_key,
192
+ CAST(SUM(line_item_unblended_cost) AS DOUBLE) AS amount
193
+ FROM ${table(config)}
194
+ WHERE ${tenantWhere(config, tenant)}
195
+ AND ${rangeWhere(range)}
196
+ AND line_item_unblended_cost > 0
197
+ ${filterWhere(filters)}
198
+ GROUP BY 1, 2, 3
199
+ `;
200
+ const relationRows = await execute(client, config, relationQuery);
201
+ const accountsByItem = new Map();
202
+ const regionsByItem = new Map();
203
+ for (const row of relationRows) {
204
+ const key = row.item_key || "Unallocated";
205
+ const add = (target, relationKey) => {
206
+ if (!target.has(key)) target.set(key, new Map());
207
+ const values = target.get(key);
208
+ values.set(relationKey, (values.get(relationKey) || 0) + amount(row.amount));
209
+ };
210
+ add(accountsByItem, row.account_key || "Unallocated");
211
+ add(regionsByItem, row.region_key || "GLOBAL");
212
+ }
213
+ const relations = (target, key, type) => [...(target.get(key) || new Map()).entries()].map(([value, cost]) => ({ key: value, label: type === "account" ? accountNames.get(value) || value : value, amount: round(cost) })).sort((a, b) => b.amount - a.amount);
214
+ summary = summary.map((item) => ({ ...item, accounts: relations(accountsByItem, item.key, "account"), regions: relations(regionsByItem, item.key, "region") }));
215
+ }
216
+ const byPeriod = new Map();
217
+ for (const item of periods) {
218
+ if (!byPeriod.has(item.start)) byPeriod.set(item.start, []);
219
+ byPeriod.get(item.start).push(item);
220
+ }
221
+ const timeline = [...byPeriod.entries()].map(([start, items]) => ({
222
+ start,
223
+ end: start,
224
+ total: round(items.reduce((sum, item) => sum + item.amount, 0)),
225
+ groups: summarize(items, accountNames),
226
+ }));
227
+ return {
228
+ groupBy,
229
+ range,
230
+ currency: "USD",
231
+ totalCost: round(summary.reduce((sum, item) => sum + item.amount, 0)),
232
+ count: summary.length,
233
+ summary,
234
+ timeline,
235
+ dataSource: "aws-cur-athena",
236
+ accessMode: "cur-readonly",
237
+ costBasis: "gross-positive-unblended",
238
+ message: groupBy === "resource" && rows.length >= config.maxRows ? `Showing the first ${config.maxRows} resource rows.` : undefined,
239
+ };
240
+ }
241
+
242
+ export async function getCurOverview({ client, config, tenant, range, trendRange = range, trendMode = "monthly", accountNames = new Map() }) {
243
+ const metricsQuery = `
244
+ SELECT
245
+ CAST(SUM(CASE WHEN line_item_unblended_cost > 0 THEN line_item_unblended_cost ELSE 0 END) AS DOUBLE) AS gross_cost,
246
+ CAST(SUM(line_item_unblended_cost) AS DOUBLE) AS net_cost,
247
+ CAST(COUNT(DISTINCT CASE WHEN line_item_unblended_cost > 0 AND line_item_resource_id IS NOT NULL AND line_item_resource_id <> '' THEN line_item_resource_id END) AS BIGINT) AS active_resources
248
+ FROM ${table(config)}
249
+ WHERE ${tenantWhere(config, tenant)} AND ${rangeWhere(range)}
250
+ `;
251
+ const [metricsRows, services, accounts, regions, trendReport] = await Promise.all([
252
+ execute(client, config, metricsQuery),
253
+ getCurReport({ client, config, tenant, range, groupBy: "service", accountNames }),
254
+ getCurReport({ client, config, tenant, range, groupBy: "account", accountNames }),
255
+ getCurReport({ client, config, tenant, range, groupBy: "region", accountNames }),
256
+ getCurReport({ client, config, tenant, range: trendRange, groupBy: "service", accountNames }),
257
+ ]);
258
+ const metrics = metricsRows[0] || {};
259
+ const grossCost = round(amount(metrics.gross_cost));
260
+ const netCost = round(amount(metrics.net_cost));
261
+ const resolvedAccounts = accounts.summary.map((item) => ({ id: item.accountId || item.key, name: item.label, region: "global", status: "active" }));
262
+ let trends = trendReport.timeline.map((item) => ({ month: item.start, amount: item.total, currency: "USD" }));
263
+ if (trendMode === "yearly") {
264
+ const years = new Map();
265
+ for (const item of trends) {
266
+ const year = String(item.month || "").slice(0, 4);
267
+ years.set(year, round((years.get(year) || 0) + item.amount));
268
+ }
269
+ const firstYear = new Date(`${trendRange.Start}T00:00:00Z`).getUTCFullYear();
270
+ const trendEnd = new Date(`${trendRange.End}T00:00:00Z`); trendEnd.setUTCDate(trendEnd.getUTCDate() - 1);
271
+ const lastYear = trendEnd.getUTCFullYear();
272
+ trends = Array.from({ length: lastYear - firstYear + 1 }, (_, offset) => {
273
+ const year = String(firstYear + offset);
274
+ return { month: `${year}-01-01`, amount: years.get(year) || 0, currency: "USD" };
275
+ });
276
+ }
277
+ return {
278
+ tenantId: tenant,
279
+ accounts: resolvedAccounts,
280
+ currency: "USD",
281
+ period: range,
282
+ totalCost: grossCost,
283
+ grossCost,
284
+ netCost,
285
+ creditsAndAdjustments: round(netCost - grossCost),
286
+ forecast: null,
287
+ activeAccounts: resolvedAccounts.length,
288
+ activeResources: Number(metrics.active_resources || 0),
289
+ trends,
290
+ topServices: services.summary.slice(0, 10),
291
+ topAccounts: accounts.summary.slice(0, 10),
292
+ topRegions: regions.summary.slice(0, 10),
293
+ topResources: [],
294
+ dataSource: "aws-cur-athena",
295
+ accessMode: "cur-readonly",
296
+ costBasis: "gross-positive-unblended",
297
+ };
298
+ }
299
+
300
+ export async function getCurTags({ client, config, tenant }) {
301
+ if (!tenant) throw new Error("A tenant is required to list CUR tags");
302
+ const query = `
303
+ SELECT DISTINCT tag_key
304
+ FROM ${table(config)}
305
+ CROSS JOIN UNNEST(map_keys(resource_tags)) AS tags(tag_key)
306
+ WHERE ${tenantWhere(config, tenant)}
307
+ AND tag_key IS NOT NULL
308
+ AND TRIM(tag_key) <> ''
309
+ ORDER BY tag_key
310
+ `;
311
+ const rows = await execute(client, config, query);
312
+ return rows.map((row) => String(row.tag_key || "").replace(/^user:/, "")).filter(Boolean);
313
+ }
314
+
315
+ export async function getCurFilterOptions({ client, config, tenant, range, tagKey, accountNames = new Map() }) {
316
+ const distinct = async (column) => execute(client, config, `
317
+ SELECT DISTINCT CAST("${column}" AS VARCHAR) AS value
318
+ FROM ${table(config)}
319
+ WHERE ${tenantWhere(config, tenant)}
320
+ AND ${rangeWhere(range)}
321
+ AND line_item_unblended_cost > 0
322
+ AND "${column}" IS NOT NULL
323
+ AND TRIM(CAST("${column}" AS VARCHAR)) <> ''
324
+ ORDER BY 1
325
+ `);
326
+ const [services, regions, accounts, tags] = await Promise.all([
327
+ distinct("product_product_name"),
328
+ distinct("product_region_code"),
329
+ distinct("line_item_usage_account_id"),
330
+ getCurTags({ client, config, tenant }),
331
+ ]);
332
+ let tagValues = [];
333
+ if (tagKey) {
334
+ tagValues = (await execute(client, config, `
335
+ SELECT DISTINCT CAST(${cur2TagExpression(tagKey)} AS VARCHAR) AS value
336
+ FROM ${table(config)}
337
+ WHERE ${tenantWhere(config, tenant)}
338
+ AND ${rangeWhere(range)}
339
+ AND line_item_unblended_cost > 0
340
+ AND ${cur2TagExpression(tagKey)} IS NOT NULL
341
+ ORDER BY 1
342
+ `)).map((row) => row.value).filter(Boolean);
343
+ }
344
+ const options = (items, type) => items.map((item) => ({ value: item.value, label: type === "account" ? accountNames.get(item.value) || item.value : item.value }));
345
+ return {
346
+ services: options(services),
347
+ regions: options(regions),
348
+ accounts: options(accounts, "account"),
349
+ tagKeys: tagKey ? [] : tags,
350
+ tagValues,
351
+ dataSource: "aws-cur-athena",
352
+ accessMode: "cur-readonly",
353
+ };
354
+ }
@@ -0,0 +1,26 @@
1
+ import { CustomerAwsContext } from "../models/customer-aws-context.model.js";
2
+
3
+ export class CustomerAwsContextService {
4
+ constructor({ repository, defaultTenant }) {
5
+ this.repository = repository;
6
+ this.defaultTenant = defaultTenant;
7
+ }
8
+
9
+ tenantId(req) {
10
+ return String(req.user?.tenant_id || req.user?.tenantId || req.headers["x-tenant-id"] || this.defaultTenant).trim() || this.defaultTenant;
11
+ }
12
+
13
+ async resolve(req) {
14
+ const tenant = this.tenantId(req);
15
+ const stored = await this.repository.findCostAccess(tenant);
16
+ const accounts = stored.accounts;
17
+ const meta = stored.meta;
18
+ return new CustomerAwsContext({ tenant, accounts, meta });
19
+ }
20
+
21
+ async recordCurStatus(tenant, status) {
22
+ if (typeof this.repository.updateCurStatus === "function") {
23
+ await this.repository.updateCurStatus(tenant, status);
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,45 @@
1
+ import { fromTemporaryCredentials } from "@aws-sdk/credential-providers";
2
+ import { truthy } from "../lib/cost-utils.js";
3
+ import { SaasCurContext } from "../models/saas-cur-context.model.js";
4
+ import { CurIngestionDescriptor } from "../models/cur-ingestion.model.js";
5
+ import { createCurClient, getCurConfig } from "./cur.service.js";
6
+
7
+ export class SaasAthenaContextService {
8
+ constructor({ env = process.env, credentialProvider = fromTemporaryCredentials, clientFactory = createCurClient } = {}) {
9
+ this.env = env;
10
+ this.credentialProvider = credentialProvider;
11
+ this.clientFactory = clientFactory;
12
+ }
13
+
14
+ resolve(customerContext) {
15
+ const allowTenantCatalog = truthy(this.env.COST_CUR_ALLOW_TENANT_CATALOG);
16
+ const tenantMeta = customerContext.meta || {};
17
+ const ingestion = new CurIngestionDescriptor({ tenant: customerContext.tenant, metadata: tenantMeta, env: this.env });
18
+ const trustedDiscoveryMeta = tenantMeta.curDiscoveredTable
19
+ ? { curTable: tenantMeta.curDiscoveredTable }
20
+ : {};
21
+ const catalogMeta = allowTenantCatalog ? tenantMeta : trustedDiscoveryMeta;
22
+ const tenantPartition = String(
23
+ this.env.COST_CUR_TENANT_PARTITION
24
+ || ingestion.tenantPartition,
25
+ ).trim();
26
+ const resolvedConfig = getCurConfig(this.env, { ...catalogMeta, curTenantPartition: tenantPartition });
27
+ const roleArn = String(this.env.COST_CUR_ROLE_ARN || "").trim();
28
+ const config = resolvedConfig;
29
+ const credentials = roleArn ? this.credentialProvider({
30
+ params: {
31
+ RoleArn: roleArn,
32
+ RoleSessionName: `meyi-cur-${customerContext.tenant}`.slice(0, 64),
33
+ ExternalId: this.env.COST_CUR_EXTERNAL_ID || undefined,
34
+ },
35
+ }) : undefined;
36
+ const client = config.enabled ? this.clientFactory({ config, credentials }) : null;
37
+ return new SaasCurContext({
38
+ tenant: customerContext.tenant,
39
+ config,
40
+ client,
41
+ credentialMode: roleArn ? "assumed-saas-cur-role" : "saas-runtime-identity",
42
+ ingestion,
43
+ });
44
+ }
45
+ }