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