@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.
- package/AGENTS.md +197 -0
- package/README.md +454 -0
- package/cur.js +2 -0
- package/index.js +2 -0
- package/package.json +36 -0
- package/src/controllers/budget.controller.js +39 -0
- package/src/controllers/cost-analysis.controller.js +28 -0
- package/src/controllers/cost.controller.js +144 -0
- package/src/cur-discovery/cur-discovery.aws.js +82 -0
- package/src/cur-discovery/cur-discovery.repository.js +177 -0
- package/src/cur-discovery/cur-discovery.service.js +112 -0
- package/src/cur-discovery/cur-discovery.worker.js +57 -0
- package/src/cur-discovery/schema.js +48 -0
- package/src/lib/cost-analysis.js +116 -0
- package/src/lib/cost-utils.js +98 -0
- package/src/lib/llm-provider.js +239 -0
- package/src/models/budget.model.js +26 -0
- package/src/models/cur-data-status.model.js +17 -0
- package/src/models/cur-ingestion.model.js +9 -0
- package/src/models/customer-aws-context.model.js +15 -0
- package/src/models/saas-cur-context.model.js +10 -0
- package/src/plugin.js +82 -0
- package/src/repositories/aws-onboarding.repository.js +134 -0
- package/src/repositories/budget-alert.repository.js +37 -0
- package/src/repositories/budget.repository.js +33 -0
- package/src/repositories/cost-analysis.repository.js +50 -0
- package/src/routes/index.js +31 -0
- package/src/schema/cost-analysis.schema.js +7 -0
- package/src/schema/cost-budget.schema.js +9 -0
- package/src/services/aws-context.service.js +1 -0
- package/src/services/budget-alert.service.js +47 -0
- package/src/services/budget.service.js +32 -0
- package/src/services/cost-analysis-data.service.js +39 -0
- package/src/services/cost-analysis.service.js +190 -0
- package/src/services/cur-provider.service.js +95 -0
- package/src/services/cur.service.js +354 -0
- package/src/services/customer-aws-context.service.js +26 -0
- 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
|
+
}
|