@electrolux-oss/plugin-infrawallet-backend 0.1.2 → 0.1.4
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/config.d.ts +19 -3
- package/dist/index.cjs.js +477 -159
- package/dist/index.cjs.js.map +1 -1
- package/migrations/20240502114057_init.js +5 -20
- package/migrations/20240625084747_separate-table-for-category-mappings-overriding.js +44 -0
- package/package.json +3 -1
- package/seeds/default_category_mappings.js +471 -0
- package/seeds/init.js +0 -214
package/dist/index.cjs.js
CHANGED
|
@@ -11,7 +11,9 @@ var clientSts = require('@aws-sdk/client-sts');
|
|
|
11
11
|
var lodash = require('lodash');
|
|
12
12
|
var moment = require('moment');
|
|
13
13
|
var armCostmanagement = require('@azure/arm-costmanagement');
|
|
14
|
+
var coreRestPipeline = require('@azure/core-rest-pipeline');
|
|
14
15
|
var identity = require('@azure/identity');
|
|
16
|
+
var bigquery = require('@google-cloud/bigquery');
|
|
15
17
|
|
|
16
18
|
function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e : { default: e }; }
|
|
17
19
|
|
|
@@ -22,50 +24,126 @@ var moment__default = /*#__PURE__*/_interopDefaultCompat(moment);
|
|
|
22
24
|
async function getCategoryMappings(database, provider) {
|
|
23
25
|
const result = {};
|
|
24
26
|
const client = await database.getClient();
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
+
const default_mappings = await client.where({ provider: provider.toLowerCase() }).select().from("category_mappings_default");
|
|
28
|
+
default_mappings.forEach((mapping) => {
|
|
27
29
|
if (typeof mapping.cloud_service_names === "string") {
|
|
28
|
-
|
|
30
|
+
JSON.parse(mapping.cloud_service_names).forEach((service) => {
|
|
31
|
+
result[service] = mapping.category;
|
|
32
|
+
});
|
|
33
|
+
} else {
|
|
34
|
+
mapping.cloud_service_names.forEach((service) => {
|
|
35
|
+
result[service] = mapping.category;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
const override_mappings = await client.where({ provider }).select().from("category_mappings_override");
|
|
40
|
+
override_mappings.forEach((mapping) => {
|
|
41
|
+
if (typeof mapping.cloud_service_names === "string") {
|
|
42
|
+
JSON.parse(mapping.cloud_service_names).forEach((service) => {
|
|
43
|
+
result[service] = mapping.category;
|
|
44
|
+
});
|
|
29
45
|
} else {
|
|
30
|
-
|
|
46
|
+
mapping.cloud_service_names.forEach((service) => {
|
|
47
|
+
result[service] = mapping.category;
|
|
48
|
+
});
|
|
31
49
|
}
|
|
32
50
|
});
|
|
33
51
|
return result;
|
|
34
52
|
}
|
|
35
53
|
function getCategoryByServiceName(serviceName, categoryMappings) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (serviceNames && serviceNames.includes(serviceName)) {
|
|
39
|
-
return key;
|
|
40
|
-
}
|
|
54
|
+
if (serviceName in categoryMappings) {
|
|
55
|
+
return categoryMappings[serviceName];
|
|
41
56
|
}
|
|
42
57
|
return "Uncategorized";
|
|
43
58
|
}
|
|
59
|
+
async function getReportsFromCache(cache, provider, configKey, query) {
|
|
60
|
+
const cacheKey = [
|
|
61
|
+
provider,
|
|
62
|
+
configKey,
|
|
63
|
+
query.filters,
|
|
64
|
+
query.groups,
|
|
65
|
+
query.granularity,
|
|
66
|
+
query.startTime,
|
|
67
|
+
query.endTime
|
|
68
|
+
].join("_");
|
|
69
|
+
const cachedCosts = await cache.get(cacheKey);
|
|
70
|
+
return cachedCosts;
|
|
71
|
+
}
|
|
72
|
+
async function setReportsToCache(cache, reports, provider, configKey, query, ttl) {
|
|
73
|
+
const cacheKey = [
|
|
74
|
+
provider,
|
|
75
|
+
configKey,
|
|
76
|
+
query.filters,
|
|
77
|
+
query.groups,
|
|
78
|
+
query.granularity,
|
|
79
|
+
query.startTime,
|
|
80
|
+
query.endTime
|
|
81
|
+
].join("_");
|
|
82
|
+
await cache.set(cacheKey, reports, {
|
|
83
|
+
ttl: ttl
|
|
84
|
+
});
|
|
85
|
+
}
|
|
44
86
|
|
|
45
87
|
class AwsClient {
|
|
46
|
-
constructor(config, database) {
|
|
88
|
+
constructor(providerName, config, database, cache, logger) {
|
|
89
|
+
this.providerName = providerName;
|
|
47
90
|
this.config = config;
|
|
48
91
|
this.database = database;
|
|
92
|
+
this.cache = cache;
|
|
93
|
+
this.logger = logger;
|
|
49
94
|
}
|
|
50
|
-
static create(config, database) {
|
|
51
|
-
return new AwsClient(config, database);
|
|
95
|
+
static create(config, database, cache, logger) {
|
|
96
|
+
return new AwsClient("AWS", config, database, cache, logger);
|
|
97
|
+
}
|
|
98
|
+
convertServiceName(serviceName) {
|
|
99
|
+
let convertedName = serviceName;
|
|
100
|
+
const prefixes = ["Amazon", "AWS"];
|
|
101
|
+
const aliases = /* @__PURE__ */ new Map([
|
|
102
|
+
["Elastic Compute Cloud - Compute", "EC2 - Instances"],
|
|
103
|
+
["Virtual Private Cloud", "VPC (Virtual Private Cloud)"],
|
|
104
|
+
["Relational Database Service", "RDS (Relational Database Service)"],
|
|
105
|
+
["Simple Storage Service", "S3 (Simple Storage Service)"],
|
|
106
|
+
["Managed Streaming for Apache Kafka", "MSK (Managed Streaming for Apache Kafka)"],
|
|
107
|
+
["Elastic Container Service for Kubernetes", "EKS (Elastic Container Service for Kubernetes)"],
|
|
108
|
+
["Elastic Container Service", "ECS (Elastic Container Service)"],
|
|
109
|
+
["EC2 Container Registry (ECR)", "ECR (Elastic Container Registry)"],
|
|
110
|
+
["Simple Queue Service", "SQS (Simple Queue Service)"],
|
|
111
|
+
["Simple Notification Service", "SNS (Simple Notification Service)"],
|
|
112
|
+
["Database Migration Service", "DMS (Database Migration Service)"]
|
|
113
|
+
]);
|
|
114
|
+
for (const prefix of prefixes) {
|
|
115
|
+
if (serviceName.startsWith(prefix)) {
|
|
116
|
+
convertedName = serviceName.slice(prefix.length).trim();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (aliases.has(convertedName)) {
|
|
120
|
+
convertedName = aliases.get(convertedName) || convertedName;
|
|
121
|
+
}
|
|
122
|
+
return `${this.providerName}/${convertedName}`;
|
|
52
123
|
}
|
|
53
124
|
async fetchCostsFromCloud(query) {
|
|
54
|
-
const conf = this.config.getOptionalConfigArray(
|
|
55
|
-
"backend.infraWallet.integrations.aws"
|
|
56
|
-
);
|
|
125
|
+
const conf = this.config.getOptionalConfigArray("backend.infraWallet.integrations.aws");
|
|
57
126
|
if (!conf) {
|
|
58
|
-
return [];
|
|
127
|
+
return { reports: [], errors: [] };
|
|
59
128
|
}
|
|
60
129
|
const promises = [];
|
|
61
130
|
const results = [];
|
|
131
|
+
const errors = [];
|
|
62
132
|
query.groups.split(",").forEach((group) => {
|
|
63
133
|
if (group.includes(":")) {
|
|
64
134
|
group.split(":");
|
|
65
135
|
}
|
|
66
136
|
});
|
|
67
137
|
for (const c of conf) {
|
|
68
|
-
const
|
|
138
|
+
const accountName = c.getString("name");
|
|
139
|
+
const cachedCosts = await getReportsFromCache(this.cache, this.providerName, accountName, query);
|
|
140
|
+
if (cachedCosts) {
|
|
141
|
+
this.logger.debug(`${this.providerName}/${accountName} costs from cache`);
|
|
142
|
+
cachedCosts.map((cost) => {
|
|
143
|
+
results.push(cost);
|
|
144
|
+
});
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
69
147
|
const accountId = c.getString("accountId");
|
|
70
148
|
const assumedRoleName = c.getString("assumedRoleName");
|
|
71
149
|
const accessKeyId = c.getOptionalString("accessKeyId");
|
|
@@ -76,7 +154,7 @@ class AwsClient {
|
|
|
76
154
|
const [k, v] = tag.split(":");
|
|
77
155
|
tagKeyValues[k.trim()] = v.trim();
|
|
78
156
|
});
|
|
79
|
-
const categoryMappings = await getCategoryMappings(this.database,
|
|
157
|
+
const categoryMappings = await getCategoryMappings(this.database, this.providerName);
|
|
80
158
|
let stsParams = {};
|
|
81
159
|
if (accessKeyId && accessKeySecret) {
|
|
82
160
|
stsParams = {
|
|
@@ -93,92 +171,172 @@ class AwsClient {
|
|
|
93
171
|
}
|
|
94
172
|
const promise = (async () => {
|
|
95
173
|
var _a, _b, _c;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
});
|
|
112
|
-
const input = {
|
|
113
|
-
TimePeriod: {
|
|
114
|
-
Start: moment__default.default(parseInt(query.startTime, 10)).format("YYYY-MM-DD"),
|
|
115
|
-
End: moment__default.default(parseInt(query.endTime, 10)).format("YYYY-MM-DD")
|
|
116
|
-
},
|
|
117
|
-
Granularity: query.granularity.toUpperCase(),
|
|
118
|
-
Filter: { Dimensions: { Key: "RECORD_TYPE", Values: ["Usage"] } },
|
|
119
|
-
GroupBy: [{ Type: "DIMENSION", Key: "SERVICE" }],
|
|
120
|
-
Metrics: ["UnblendedCost"]
|
|
121
|
-
};
|
|
122
|
-
const getCostCommand = new clientCostExplorer.GetCostAndUsageCommand(input);
|
|
123
|
-
const costAndusageResponse = await awsCeClient.send(getCostCommand);
|
|
124
|
-
const transformedData = lodash.reduce(
|
|
125
|
-
costAndusageResponse.ResultsByTime,
|
|
126
|
-
(acc, row) => {
|
|
127
|
-
var _a2;
|
|
128
|
-
const rowTime = (_a2 = row.TimePeriod) == null ? void 0 : _a2.Start;
|
|
129
|
-
const period = rowTime ? rowTime.substring(0, 7) : "unknown";
|
|
130
|
-
if (row.Groups) {
|
|
131
|
-
row.Groups.forEach((group) => {
|
|
132
|
-
var _a3;
|
|
133
|
-
const groupKeys = group.Keys ? group.Keys[0] : "";
|
|
134
|
-
const keyName = `${name}_${groupKeys}`;
|
|
135
|
-
if (!acc[keyName]) {
|
|
136
|
-
acc[keyName] = {
|
|
137
|
-
id: keyName,
|
|
138
|
-
name,
|
|
139
|
-
service: `${groupKeys} (AWS)`,
|
|
140
|
-
category: getCategoryByServiceName(
|
|
141
|
-
groupKeys,
|
|
142
|
-
categoryMappings
|
|
143
|
-
),
|
|
144
|
-
provider: "AWS",
|
|
145
|
-
reports: {},
|
|
146
|
-
...tagKeyValues
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
const groupMetrics = group.Metrics;
|
|
150
|
-
if (groupMetrics !== void 0) {
|
|
151
|
-
acc[keyName].reports[period] = parseFloat(
|
|
152
|
-
(_a3 = groupMetrics.UnblendedCost.Amount) != null ? _a3 : "0.0"
|
|
153
|
-
);
|
|
154
|
-
}
|
|
155
|
-
});
|
|
174
|
+
try {
|
|
175
|
+
const client = new clientSts.STSClient(stsParams);
|
|
176
|
+
const commandInput = {
|
|
177
|
+
// AssumeRoleRequest
|
|
178
|
+
RoleArn: `arn:aws:iam::${accountId}:role/${assumedRoleName}`,
|
|
179
|
+
RoleSessionName: "AssumeRoleSession1"
|
|
180
|
+
};
|
|
181
|
+
const assumeRoleCommand = new clientSts.AssumeRoleCommand(commandInput);
|
|
182
|
+
const assumeRoleResponse = await client.send(assumeRoleCommand);
|
|
183
|
+
const awsCeClient = new clientCostExplorer.CostExplorerClient({
|
|
184
|
+
region: "us-east-1",
|
|
185
|
+
credentials: {
|
|
186
|
+
accessKeyId: (_a = assumeRoleResponse.Credentials) == null ? void 0 : _a.AccessKeyId,
|
|
187
|
+
secretAccessKey: (_b = assumeRoleResponse.Credentials) == null ? void 0 : _b.SecretAccessKey,
|
|
188
|
+
sessionToken: (_c = assumeRoleResponse.Credentials) == null ? void 0 : _c.SessionToken
|
|
156
189
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
190
|
+
});
|
|
191
|
+
let costAndUsageResults = [];
|
|
192
|
+
let nextPageToken = void 0;
|
|
193
|
+
do {
|
|
194
|
+
const input = {
|
|
195
|
+
TimePeriod: {
|
|
196
|
+
Start: moment__default.default(parseInt(query.startTime, 10)).format("YYYY-MM-DD"),
|
|
197
|
+
End: moment__default.default(parseInt(query.endTime, 10)).format("YYYY-MM-DD")
|
|
198
|
+
},
|
|
199
|
+
Granularity: query.granularity.toUpperCase(),
|
|
200
|
+
Filter: { Dimensions: { Key: "RECORD_TYPE", Values: ["Usage"] } },
|
|
201
|
+
GroupBy: [{ Type: "DIMENSION", Key: "SERVICE" }],
|
|
202
|
+
Metrics: ["UnblendedCost"],
|
|
203
|
+
NextPageToken: nextPageToken
|
|
204
|
+
};
|
|
205
|
+
const getCostCommand = new clientCostExplorer.GetCostAndUsageCommand(input);
|
|
206
|
+
const costAndUsageResponse = await awsCeClient.send(getCostCommand);
|
|
207
|
+
costAndUsageResults = costAndUsageResults.concat(costAndUsageResponse.ResultsByTime);
|
|
208
|
+
nextPageToken = costAndUsageResponse.NextPageToken;
|
|
209
|
+
} while (nextPageToken);
|
|
210
|
+
const transformedData = lodash.reduce(
|
|
211
|
+
costAndUsageResults,
|
|
212
|
+
(accumulator, row) => {
|
|
213
|
+
var _a2;
|
|
214
|
+
const rowTime = (_a2 = row.TimePeriod) == null ? void 0 : _a2.Start;
|
|
215
|
+
let period = "unknown";
|
|
216
|
+
if (rowTime) {
|
|
217
|
+
if (query.granularity.toUpperCase() === "MONTHLY") {
|
|
218
|
+
period = rowTime.substring(0, 7);
|
|
219
|
+
} else {
|
|
220
|
+
period = rowTime;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (row.Groups) {
|
|
224
|
+
row.Groups.forEach((group) => {
|
|
225
|
+
var _a3;
|
|
226
|
+
const serviceName = group.Keys ? group.Keys[0] : "";
|
|
227
|
+
const keyName = `${accountName}_${serviceName}`;
|
|
228
|
+
if (!accumulator[keyName]) {
|
|
229
|
+
accumulator[keyName] = {
|
|
230
|
+
id: keyName,
|
|
231
|
+
name: `${this.providerName}/${accountName}`,
|
|
232
|
+
service: this.convertServiceName(serviceName),
|
|
233
|
+
category: getCategoryByServiceName(serviceName, categoryMappings),
|
|
234
|
+
provider: this.providerName,
|
|
235
|
+
reports: {},
|
|
236
|
+
...tagKeyValues
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
const groupMetrics = group.Metrics;
|
|
240
|
+
if (groupMetrics !== void 0) {
|
|
241
|
+
accumulator[keyName].reports[period] = parseFloat((_a3 = groupMetrics.UnblendedCost.Amount) != null ? _a3 : "0.0");
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
return accumulator;
|
|
246
|
+
},
|
|
247
|
+
{}
|
|
248
|
+
);
|
|
249
|
+
await setReportsToCache(
|
|
250
|
+
this.cache,
|
|
251
|
+
Object.values(transformedData),
|
|
252
|
+
this.providerName,
|
|
253
|
+
accountName,
|
|
254
|
+
query,
|
|
255
|
+
60 * 60 * 2 * 1e3
|
|
256
|
+
);
|
|
257
|
+
Object.values(transformedData).map((value) => {
|
|
258
|
+
results.push(value);
|
|
259
|
+
});
|
|
260
|
+
} catch (e) {
|
|
261
|
+
this.logger.error(e);
|
|
262
|
+
errors.push({
|
|
263
|
+
provider: this.providerName,
|
|
264
|
+
name: `${this.providerName}/${accountName}`,
|
|
265
|
+
error: e.message
|
|
266
|
+
});
|
|
267
|
+
}
|
|
164
268
|
})();
|
|
165
269
|
promises.push(promise);
|
|
166
270
|
}
|
|
167
271
|
await Promise.all(promises);
|
|
168
|
-
return
|
|
272
|
+
return {
|
|
273
|
+
reports: results,
|
|
274
|
+
errors
|
|
275
|
+
};
|
|
169
276
|
}
|
|
170
277
|
}
|
|
171
278
|
|
|
172
279
|
class AzureClient {
|
|
173
|
-
constructor(config, database) {
|
|
280
|
+
constructor(providerName, config, database, cache, logger) {
|
|
281
|
+
this.providerName = providerName;
|
|
174
282
|
this.config = config;
|
|
175
283
|
this.database = database;
|
|
284
|
+
this.cache = cache;
|
|
285
|
+
this.logger = logger;
|
|
286
|
+
}
|
|
287
|
+
static create(config, database, cache, logger) {
|
|
288
|
+
return new AzureClient("Azure", config, database, cache, logger);
|
|
289
|
+
}
|
|
290
|
+
convertServiceName(serviceName) {
|
|
291
|
+
let convertedName = serviceName;
|
|
292
|
+
const prefixes = ["Azure"];
|
|
293
|
+
for (const prefix of prefixes) {
|
|
294
|
+
if (serviceName.startsWith(prefix)) {
|
|
295
|
+
convertedName = serviceName.slice(prefix.length).trim();
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return `${this.providerName}/${convertedName}`;
|
|
299
|
+
}
|
|
300
|
+
formatDate(dateNumber) {
|
|
301
|
+
const dateString = dateNumber.toString();
|
|
302
|
+
if (dateString.length !== 8) {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
const year = dateString.slice(0, 4);
|
|
306
|
+
const month = dateString.slice(4, 6);
|
|
307
|
+
const day = dateString.slice(6);
|
|
308
|
+
return `${year}-${month}-${day}`;
|
|
176
309
|
}
|
|
177
|
-
|
|
178
|
-
|
|
310
|
+
async fetchDataWithRetry(client, url, body, maxRetries = 5) {
|
|
311
|
+
let retries = 0;
|
|
312
|
+
while (retries < maxRetries) {
|
|
313
|
+
const request = coreRestPipeline.createPipelineRequest({
|
|
314
|
+
url,
|
|
315
|
+
method: "POST",
|
|
316
|
+
body: JSON.stringify(body),
|
|
317
|
+
headers: coreRestPipeline.createHttpHeaders({
|
|
318
|
+
"Content-Type": "application/json"
|
|
319
|
+
})
|
|
320
|
+
});
|
|
321
|
+
const response = await client.pipeline.sendRequest(client, request);
|
|
322
|
+
if (response.status === 200) {
|
|
323
|
+
return JSON.parse(response.bodyAsText || "{}");
|
|
324
|
+
} else if (response.status === 429) {
|
|
325
|
+
const retryAfter = parseInt(
|
|
326
|
+
response.headers.get("x-ms-ratelimit-microsoft.costmanagement-entity-retry-after") || "60",
|
|
327
|
+
10
|
|
328
|
+
);
|
|
329
|
+
this.logger.warn(`Hit Azure rate limit, retrying after ${retryAfter} seconds...`);
|
|
330
|
+
await new Promise((resolve) => setTimeout(resolve, retryAfter * 1e3));
|
|
331
|
+
retries++;
|
|
332
|
+
} else {
|
|
333
|
+
throw new Error(response.bodyAsText);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
throw new Error("Max retries exceeded");
|
|
179
337
|
}
|
|
180
338
|
async queryAzureCostExplorer(azureClient, subscription, granularity, groups, startDate, endDate) {
|
|
181
|
-
const
|
|
339
|
+
const url = `https://management.azure.com/subscriptions/${subscription}/providers/Microsoft.CostManagement/query?api-version=2022-10-01`;
|
|
182
340
|
const query = {
|
|
183
341
|
type: "ActualCost",
|
|
184
342
|
dataset: {
|
|
@@ -188,34 +346,43 @@ class AzureClient {
|
|
|
188
346
|
},
|
|
189
347
|
timeframe: "Custom",
|
|
190
348
|
timePeriod: {
|
|
191
|
-
from: startDate.
|
|
192
|
-
to: endDate.
|
|
349
|
+
from: startDate.toDate(),
|
|
350
|
+
to: endDate.toDate()
|
|
193
351
|
}
|
|
194
352
|
};
|
|
195
|
-
|
|
196
|
-
|
|
353
|
+
let result = await this.fetchDataWithRetry(azureClient, url, query);
|
|
354
|
+
let allResults = result.properties.rows;
|
|
355
|
+
while (result.properties.nextLink) {
|
|
356
|
+
result = await this.fetchDataWithRetry(azureClient, result.properties.nextLink, query);
|
|
357
|
+
allResults = allResults.concat(result.properties.rows);
|
|
358
|
+
}
|
|
359
|
+
return allResults;
|
|
197
360
|
}
|
|
198
361
|
async fetchCostsFromCloud(query) {
|
|
199
|
-
const conf = this.config.getOptionalConfigArray(
|
|
200
|
-
"backend.infraWallet.integrations.azure"
|
|
201
|
-
);
|
|
362
|
+
const conf = this.config.getOptionalConfigArray("backend.infraWallet.integrations.azure");
|
|
202
363
|
if (!conf) {
|
|
203
|
-
return [];
|
|
364
|
+
return { reports: [], errors: [] };
|
|
204
365
|
}
|
|
366
|
+
const categoryMappings = await getCategoryMappings(this.database, this.providerName);
|
|
205
367
|
const promises = [];
|
|
206
368
|
const results = [];
|
|
369
|
+
const errors = [];
|
|
207
370
|
const groupPairs = [{ type: "Dimension", name: "ServiceName" }];
|
|
208
371
|
for (const c of conf) {
|
|
209
|
-
const
|
|
372
|
+
const accountName = c.getString("name");
|
|
373
|
+
const cachedCosts = await getReportsFromCache(this.cache, this.providerName, accountName, query);
|
|
374
|
+
if (cachedCosts) {
|
|
375
|
+
this.logger.debug(`${this.providerName}/${accountName} costs from cache`);
|
|
376
|
+
cachedCosts.map((cost) => {
|
|
377
|
+
results.push(cost);
|
|
378
|
+
});
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
210
381
|
const subscriptionId = c.getString("subscriptionId");
|
|
211
382
|
const tenantId = c.getString("tenantId");
|
|
212
383
|
const clientId = c.getString("clientId");
|
|
213
384
|
const clientSecret = c.getString("clientSecret");
|
|
214
|
-
const credential = new identity.ClientSecretCredential(
|
|
215
|
-
tenantId,
|
|
216
|
-
clientId,
|
|
217
|
-
clientSecret
|
|
218
|
-
);
|
|
385
|
+
const credential = new identity.ClientSecretCredential(tenantId, clientId, clientSecret);
|
|
219
386
|
const client = new armCostmanagement.CostManagementClient(credential);
|
|
220
387
|
const tags = c.getOptionalStringArray("tags");
|
|
221
388
|
const tagKeyValues = {};
|
|
@@ -223,10 +390,6 @@ class AzureClient {
|
|
|
223
390
|
const [k, v] = tag.split(":");
|
|
224
391
|
tagKeyValues[k.trim()] = v.trim();
|
|
225
392
|
});
|
|
226
|
-
const categoryMappings = await getCategoryMappings(
|
|
227
|
-
this.database,
|
|
228
|
-
"azure"
|
|
229
|
-
);
|
|
230
393
|
const promise = (async () => {
|
|
231
394
|
try {
|
|
232
395
|
const costResponse = await this.queryAzureCostExplorer(
|
|
@@ -238,77 +401,234 @@ class AzureClient {
|
|
|
238
401
|
moment__default.default(parseInt(query.endTime, 10))
|
|
239
402
|
);
|
|
240
403
|
const transformedData = lodash.reduce(
|
|
241
|
-
costResponse
|
|
242
|
-
(
|
|
243
|
-
|
|
404
|
+
costResponse,
|
|
405
|
+
(accumulator, row) => {
|
|
406
|
+
const cost = row[0];
|
|
407
|
+
let date = row[1];
|
|
408
|
+
const serviceName = row[2];
|
|
409
|
+
if (query.granularity.toUpperCase() === "DAILY") {
|
|
410
|
+
date = this.formatDate(date);
|
|
411
|
+
}
|
|
412
|
+
let keyName = accountName;
|
|
244
413
|
for (let i = 0; i < groupPairs.length; i++) {
|
|
245
414
|
keyName += `->${row[i + 2]}`;
|
|
246
415
|
}
|
|
416
|
+
if (!accumulator[keyName]) {
|
|
417
|
+
accumulator[keyName] = {
|
|
418
|
+
id: keyName,
|
|
419
|
+
name: `${this.providerName}/${accountName}`,
|
|
420
|
+
service: this.convertServiceName(serviceName),
|
|
421
|
+
category: getCategoryByServiceName(serviceName, categoryMappings),
|
|
422
|
+
provider: this.providerName,
|
|
423
|
+
reports: {},
|
|
424
|
+
...tagKeyValues
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
if (!moment__default.default(date).isBefore(moment__default.default(parseInt(query.startTime, 10)))) {
|
|
428
|
+
if (query.granularity.toUpperCase() === "MONTHLY") {
|
|
429
|
+
const yearMonth = date.substring(0, 7);
|
|
430
|
+
accumulator[keyName].reports[yearMonth] = parseFloat(cost);
|
|
431
|
+
} else {
|
|
432
|
+
accumulator[keyName].reports[date] = parseFloat(cost);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return accumulator;
|
|
436
|
+
},
|
|
437
|
+
{}
|
|
438
|
+
);
|
|
439
|
+
await setReportsToCache(
|
|
440
|
+
this.cache,
|
|
441
|
+
Object.values(transformedData),
|
|
442
|
+
this.providerName,
|
|
443
|
+
accountName,
|
|
444
|
+
query,
|
|
445
|
+
60 * 60 * 2 * 1e3
|
|
446
|
+
);
|
|
447
|
+
Object.values(transformedData).map((value) => {
|
|
448
|
+
results.push(value);
|
|
449
|
+
});
|
|
450
|
+
} catch (e) {
|
|
451
|
+
this.logger.error(e);
|
|
452
|
+
errors.push({
|
|
453
|
+
provider: this.providerName,
|
|
454
|
+
name: `${this.providerName}/${accountName}`,
|
|
455
|
+
error: e.message
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
})();
|
|
459
|
+
promises.push(promise);
|
|
460
|
+
}
|
|
461
|
+
await Promise.all(promises);
|
|
462
|
+
return {
|
|
463
|
+
reports: results,
|
|
464
|
+
errors
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
class GCPClient {
|
|
470
|
+
constructor(providerName, config, database, cache, logger) {
|
|
471
|
+
this.providerName = providerName;
|
|
472
|
+
this.config = config;
|
|
473
|
+
this.database = database;
|
|
474
|
+
this.cache = cache;
|
|
475
|
+
this.logger = logger;
|
|
476
|
+
}
|
|
477
|
+
static create(config, database, cache, logger) {
|
|
478
|
+
return new GCPClient("GCP", config, database, cache, logger);
|
|
479
|
+
}
|
|
480
|
+
convertServiceName(serviceName) {
|
|
481
|
+
let convertedName = serviceName;
|
|
482
|
+
const prefixes = ["Google Cloud"];
|
|
483
|
+
for (const prefix of prefixes) {
|
|
484
|
+
if (serviceName.startsWith(prefix)) {
|
|
485
|
+
convertedName = serviceName.slice(prefix.length).trim();
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return `${this.providerName}/${convertedName}`;
|
|
489
|
+
}
|
|
490
|
+
async queryBigQuery(keyFilePath, projectId, datasetId, tableId, query) {
|
|
491
|
+
const options = {
|
|
492
|
+
keyFilename: keyFilePath,
|
|
493
|
+
projectId
|
|
494
|
+
};
|
|
495
|
+
const bigquery$1 = new bigquery.BigQuery(options);
|
|
496
|
+
try {
|
|
497
|
+
const periodFormat = query.granularity.toUpperCase() === "MONTHLY" ? "%Y-%m" : "%Y-%m-%d";
|
|
498
|
+
const sql = `
|
|
499
|
+
SELECT
|
|
500
|
+
project.name AS project,
|
|
501
|
+
service.description AS service,
|
|
502
|
+
FORMAT_TIMESTAMP('${periodFormat}', usage_start_time) AS period,
|
|
503
|
+
SUM(cost) AS total_cost
|
|
504
|
+
FROM
|
|
505
|
+
\`${projectId}.${datasetId}.${tableId}\`
|
|
506
|
+
WHERE
|
|
507
|
+
project.name IS NOT NULL
|
|
508
|
+
AND cost > 0
|
|
509
|
+
AND usage_start_time >= TIMESTAMP_MILLIS(${query.startTime})
|
|
510
|
+
AND usage_start_time <= TIMESTAMP_MILLIS(${query.endTime})
|
|
511
|
+
GROUP BY
|
|
512
|
+
project, service, period
|
|
513
|
+
ORDER BY
|
|
514
|
+
project, period, total_cost DESC`;
|
|
515
|
+
const [job] = await bigquery$1.createQueryJob({
|
|
516
|
+
query: sql,
|
|
517
|
+
location: "US"
|
|
518
|
+
});
|
|
519
|
+
const [rows] = await job.getQueryResults();
|
|
520
|
+
return rows;
|
|
521
|
+
} catch (err) {
|
|
522
|
+
throw new Error(err.message);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
async fetchCostsFromCloud(query) {
|
|
526
|
+
const conf = this.config.getOptionalConfigArray("backend.infraWallet.integrations.gcp");
|
|
527
|
+
if (!conf) {
|
|
528
|
+
return { reports: [], errors: [] };
|
|
529
|
+
}
|
|
530
|
+
const promises = [];
|
|
531
|
+
const results = [];
|
|
532
|
+
const errors = [];
|
|
533
|
+
for (const c of conf) {
|
|
534
|
+
const accountName = c.getString("name");
|
|
535
|
+
const cachedCosts = await getReportsFromCache(this.cache, this.providerName, accountName, query);
|
|
536
|
+
if (cachedCosts) {
|
|
537
|
+
this.logger.debug(`${this.providerName}/${accountName} costs from cache`);
|
|
538
|
+
cachedCosts.map((cost) => {
|
|
539
|
+
results.push(cost);
|
|
540
|
+
});
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
const keyFilePath = c.getString("keyFilePath");
|
|
544
|
+
const projectId = c.getString("projectId");
|
|
545
|
+
const datasetId = c.getString("datasetId");
|
|
546
|
+
const tableId = c.getString("tableId");
|
|
547
|
+
const tags = c.getOptionalStringArray("tags");
|
|
548
|
+
const tagKeyValues = {};
|
|
549
|
+
tags == null ? void 0 : tags.forEach((tag) => {
|
|
550
|
+
const [k, v] = tag.split(":");
|
|
551
|
+
tagKeyValues[k.trim()] = v.trim();
|
|
552
|
+
});
|
|
553
|
+
const categoryMappings = await getCategoryMappings(this.database, this.providerName);
|
|
554
|
+
const promise = (async () => {
|
|
555
|
+
try {
|
|
556
|
+
const costResponse = await this.queryBigQuery(keyFilePath, projectId, datasetId, tableId, query);
|
|
557
|
+
const transformedData = lodash.reduce(
|
|
558
|
+
costResponse,
|
|
559
|
+
(acc, row) => {
|
|
560
|
+
const period = row.period;
|
|
561
|
+
const keyName = `${accountName}_${row.project}_${row.service}`;
|
|
247
562
|
if (!acc[keyName]) {
|
|
248
563
|
acc[keyName] = {
|
|
249
564
|
id: keyName,
|
|
250
|
-
name
|
|
251
|
-
service:
|
|
252
|
-
category: getCategoryByServiceName(row
|
|
253
|
-
provider:
|
|
565
|
+
name: `${this.providerName}/${accountName}`,
|
|
566
|
+
service: this.convertServiceName(row.service),
|
|
567
|
+
category: getCategoryByServiceName(row.service, categoryMappings),
|
|
568
|
+
provider: this.providerName,
|
|
254
569
|
reports: {},
|
|
570
|
+
...{ project: row.project },
|
|
571
|
+
// TODO: how should we handle the project field? for now, we add project name as a field in the report
|
|
255
572
|
...tagKeyValues
|
|
573
|
+
// note that if there is a tag `project:foo` in config, it overrides the project field set above
|
|
256
574
|
};
|
|
257
575
|
}
|
|
258
|
-
|
|
259
|
-
acc[keyName].reports[row[1].substring(0, 7)] = parseFloat(
|
|
260
|
-
row[0]
|
|
261
|
-
);
|
|
262
|
-
}
|
|
576
|
+
acc[keyName].reports[period] = row.total_cost;
|
|
263
577
|
return acc;
|
|
264
578
|
},
|
|
265
579
|
{}
|
|
266
580
|
);
|
|
581
|
+
await setReportsToCache(
|
|
582
|
+
this.cache,
|
|
583
|
+
Object.values(transformedData),
|
|
584
|
+
this.providerName,
|
|
585
|
+
accountName,
|
|
586
|
+
query,
|
|
587
|
+
60 * 60 * 2 * 1e3
|
|
588
|
+
);
|
|
267
589
|
Object.values(transformedData).map((value) => {
|
|
268
590
|
results.push(value);
|
|
269
591
|
});
|
|
270
592
|
} catch (e) {
|
|
271
|
-
|
|
593
|
+
this.logger.error(e);
|
|
594
|
+
errors.push({
|
|
595
|
+
provider: this.providerName,
|
|
596
|
+
name: `${this.providerName}/${accountName}`,
|
|
597
|
+
error: e.message
|
|
598
|
+
});
|
|
272
599
|
}
|
|
273
600
|
})();
|
|
274
601
|
promises.push(promise);
|
|
275
602
|
}
|
|
276
603
|
await Promise.all(promises);
|
|
277
|
-
return
|
|
604
|
+
return {
|
|
605
|
+
reports: results,
|
|
606
|
+
errors
|
|
607
|
+
};
|
|
278
608
|
}
|
|
279
609
|
}
|
|
280
610
|
|
|
281
611
|
async function setUpDatabase(database) {
|
|
282
612
|
var _a;
|
|
283
613
|
const client = await database.getClient();
|
|
284
|
-
const migrationsDir = backendPluginApi.resolvePackagePath(
|
|
285
|
-
"@electrolux-oss/plugin-infrawallet-backend",
|
|
286
|
-
"migrations"
|
|
287
|
-
);
|
|
614
|
+
const migrationsDir = backendPluginApi.resolvePackagePath("@electrolux-oss/plugin-infrawallet-backend", "migrations");
|
|
288
615
|
if (!((_a = database.migrations) == null ? void 0 : _a.skip)) {
|
|
289
616
|
await client.migrate.latest({
|
|
290
617
|
directory: migrationsDir
|
|
291
618
|
});
|
|
292
619
|
}
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
);
|
|
296
|
-
if (category_mappings_count[0].c === 0 || category_mappings_count[0].c === "0") {
|
|
297
|
-
const seedsDir = backendPluginApi.resolvePackagePath(
|
|
298
|
-
"@electrolux-oss/plugin-infrawallet-backend",
|
|
299
|
-
"seeds"
|
|
300
|
-
);
|
|
301
|
-
await client.seed.run({ directory: seedsDir });
|
|
302
|
-
}
|
|
620
|
+
const seedsDir = backendPluginApi.resolvePackagePath("@electrolux-oss/plugin-infrawallet-backend", "seeds");
|
|
621
|
+
await client.seed.run({ directory: seedsDir });
|
|
303
622
|
}
|
|
304
623
|
async function createRouter(options) {
|
|
305
624
|
const { logger, config, cache, database } = options;
|
|
306
625
|
await setUpDatabase(database);
|
|
307
626
|
const router = Router__default.default();
|
|
308
627
|
router.use(express__default.default.json());
|
|
309
|
-
const azureClient = AzureClient.create(config, database);
|
|
310
|
-
const awsClient = AwsClient.create(config, database);
|
|
311
|
-
const
|
|
628
|
+
const azureClient = AzureClient.create(config, database, cache, logger);
|
|
629
|
+
const awsClient = AwsClient.create(config, database, cache, logger);
|
|
630
|
+
const gcpClient = GCPClient.create(config, database, cache, logger);
|
|
631
|
+
const cloudClients = [azureClient, awsClient, gcpClient];
|
|
312
632
|
router.get("/health", (_, response) => {
|
|
313
633
|
logger.info("PONG!");
|
|
314
634
|
response.json({ status: "ok" });
|
|
@@ -321,42 +641,40 @@ async function createRouter(options) {
|
|
|
321
641
|
const endTime = request.query.endTime;
|
|
322
642
|
const promises = [];
|
|
323
643
|
const results = [];
|
|
644
|
+
const errors = [];
|
|
324
645
|
cloudClients.forEach(async (client) => {
|
|
325
646
|
const fetchCloudCosts = (async () => {
|
|
326
|
-
|
|
327
|
-
client.
|
|
328
|
-
filters,
|
|
329
|
-
groups,
|
|
330
|
-
granularity,
|
|
331
|
-
startTime,
|
|
332
|
-
endTime
|
|
333
|
-
].join("_");
|
|
334
|
-
const cachedCosts = await cache.get(cacheKey);
|
|
335
|
-
if (cachedCosts) {
|
|
336
|
-
logger.debug(`${client.constructor.name} costs from cache`);
|
|
337
|
-
cachedCosts.forEach((cost) => {
|
|
338
|
-
results.push(cost);
|
|
339
|
-
});
|
|
340
|
-
} else {
|
|
341
|
-
const costs = await client.fetchCostsFromCloud({
|
|
647
|
+
try {
|
|
648
|
+
const clientResponse = await client.fetchCostsFromCloud({
|
|
342
649
|
filters,
|
|
343
650
|
groups,
|
|
344
651
|
granularity,
|
|
345
652
|
startTime,
|
|
346
653
|
endTime
|
|
347
654
|
});
|
|
348
|
-
|
|
349
|
-
|
|
655
|
+
clientResponse.errors.forEach((e) => {
|
|
656
|
+
errors.push(e);
|
|
350
657
|
});
|
|
351
|
-
|
|
658
|
+
clientResponse.reports.forEach((cost) => {
|
|
352
659
|
results.push(cost);
|
|
353
660
|
});
|
|
661
|
+
} catch (e) {
|
|
662
|
+
logger.error(e);
|
|
663
|
+
errors.push({
|
|
664
|
+
provider: client.constructor.name,
|
|
665
|
+
name: client.constructor.name,
|
|
666
|
+
error: e.message
|
|
667
|
+
});
|
|
354
668
|
}
|
|
355
669
|
})();
|
|
356
670
|
promises.push(fetchCloudCosts);
|
|
357
671
|
});
|
|
358
672
|
await Promise.all(promises);
|
|
359
|
-
|
|
673
|
+
if (errors.length > 0) {
|
|
674
|
+
response.status(207).json({ data: results, errors, status: 207 });
|
|
675
|
+
} else {
|
|
676
|
+
response.json({ data: results, errors, status: 200 });
|
|
677
|
+
}
|
|
360
678
|
});
|
|
361
679
|
router.use(backendCommon.errorHandler());
|
|
362
680
|
return router;
|