@letthem/backstage-plugin-aws-cost-insights-backend 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +299 -0
- package/config.d.ts +54 -0
- package/dist/index.cjs.js +10 -0
- package/dist/index.cjs.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/plugin.cjs.js +30 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/service/S3CostDataClient.cjs.js +285 -0
- package/dist/service/S3CostDataClient.cjs.js.map +1 -0
- package/dist/service/S3CostInsightsClient.cjs.js +94 -0
- package/dist/service/S3CostInsightsClient.cjs.js.map +1 -0
- package/dist/service/router.cjs.js +101 -0
- package/dist/service/router.cjs.js.map +1 -0
- package/package.json +77 -0
package/README.md
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# Cost Insights Backend Plugin
|
|
2
|
+
|
|
3
|
+
Backstage backend plugin that serves EC2 cost insights from S3 CUR-derived data.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
This plugin:
|
|
8
|
+
|
|
9
|
+
- reads daily cost data files from S3
|
|
10
|
+
- aggregates costs by resource
|
|
11
|
+
- provides monthly rollups
|
|
12
|
+
- exposes REST endpoints consumed by the frontend plugin
|
|
13
|
+
|
|
14
|
+
## Configuration
|
|
15
|
+
|
|
16
|
+
```yaml
|
|
17
|
+
costInsights:
|
|
18
|
+
environments:
|
|
19
|
+
- dev
|
|
20
|
+
- stg
|
|
21
|
+
- prod
|
|
22
|
+
defaultEnvironment: prod
|
|
23
|
+
auth:
|
|
24
|
+
allowUnauthenticated: false
|
|
25
|
+
s3:
|
|
26
|
+
region: ap-northeast-2
|
|
27
|
+
bucket: your-cur-result-bucket
|
|
28
|
+
# Optional: local SSO profile
|
|
29
|
+
# profile: your-aws-profile
|
|
30
|
+
# Optional: prefix template
|
|
31
|
+
# Tokens: {environment}, {yearMonth}, {year}, {month}
|
|
32
|
+
dailyPrefixTemplate: '{environment}/daily/{yearMonth}/'
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Auth Behavior
|
|
36
|
+
|
|
37
|
+
- `auth.allowUnauthenticated: false` -> `user-cookie`
|
|
38
|
+
- `auth.allowUnauthenticated: true` -> `unauthenticated`
|
|
39
|
+
|
|
40
|
+
Default is `false`.
|
|
41
|
+
|
|
42
|
+
## AWS Credentials
|
|
43
|
+
|
|
44
|
+
- If `s3.profile` is set, plugin uses that profile (local SSO use case).
|
|
45
|
+
- If omitted, plugin uses AWS default credential chain:
|
|
46
|
+
- IRSA (EKS)
|
|
47
|
+
- EC2 Instance Profile
|
|
48
|
+
- environment variables
|
|
49
|
+
- shared credentials/default profile
|
|
50
|
+
|
|
51
|
+
## API Endpoints
|
|
52
|
+
|
|
53
|
+
- `GET /health`
|
|
54
|
+
- `GET /config`
|
|
55
|
+
- `GET /last-complete-date?environment=<env>`
|
|
56
|
+
- `GET /product/ec2/insights?intervals=<ISO_START>/<ISO_END>&environment=<env>`
|
|
57
|
+
|
|
58
|
+
## Query Rules
|
|
59
|
+
|
|
60
|
+
- `environment` must exist in `costInsights.environments`.
|
|
61
|
+
- `intervals` must be valid ISO interval (`start/end`).
|
|
62
|
+
- `start <= end`.
|
|
63
|
+
- max interval span is 24 months.
|
|
64
|
+
|
|
65
|
+
## Data Loading Notes
|
|
66
|
+
|
|
67
|
+
- The plugin resolves the latest `run=` prefix under month prefix.
|
|
68
|
+
- It reads all data files in the run prefix (not just one file).
|
|
69
|
+
- Non-numeric cost/usage rows are skipped to avoid `NaN` aggregation.
|
|
70
|
+
|
|
71
|
+
## Input Data Contract
|
|
72
|
+
|
|
73
|
+
This plugin is data-source agnostic. It does not require Athena specifically.
|
|
74
|
+
Any upstream pipeline is valid as long as it writes data in the contract below.
|
|
75
|
+
|
|
76
|
+
### Storage Layout
|
|
77
|
+
|
|
78
|
+
- Base location is defined by `costInsights.s3.bucket`.
|
|
79
|
+
- Prefix is resolved by `costInsights.s3.dailyPrefixTemplate`.
|
|
80
|
+
- Default template:
|
|
81
|
+
- `'{environment}/daily/{yearMonth}/'`
|
|
82
|
+
- Under each month prefix, plugin expects one or more `run=` folders:
|
|
83
|
+
- Example: `prd/daily/2026-01/run=2026-01-16T11-14-00/`
|
|
84
|
+
|
|
85
|
+
### File Discovery Rules
|
|
86
|
+
|
|
87
|
+
- All files under selected `run=` prefix are scanned.
|
|
88
|
+
- Files ending with `-manifest.csv` are ignored.
|
|
89
|
+
- Files ending with `.metadata` are ignored.
|
|
90
|
+
- Directory markers (`.../`) are ignored.
|
|
91
|
+
|
|
92
|
+
### File Format
|
|
93
|
+
|
|
94
|
+
- NDJSON (JSON Lines): one JSON object per line.
|
|
95
|
+
- UTF-8 text.
|
|
96
|
+
|
|
97
|
+
### Required Fields (per line)
|
|
98
|
+
|
|
99
|
+
- `usage_date`: string, ISO date (for example `2026-01-15`)
|
|
100
|
+
- `resource_id`: string
|
|
101
|
+
- `resource_type`: string
|
|
102
|
+
- `total_cost`: number-like value
|
|
103
|
+
- `usage_amount`: number-like value
|
|
104
|
+
|
|
105
|
+
### Optional Fields
|
|
106
|
+
|
|
107
|
+
- `product_instance_type`: string
|
|
108
|
+
- `product_volume_type`: string
|
|
109
|
+
|
|
110
|
+
### Validation Behavior
|
|
111
|
+
|
|
112
|
+
- If `total_cost` or `usage_amount` is not numeric, that row is skipped.
|
|
113
|
+
- Resource costs are aggregated by `resource_id`.
|
|
114
|
+
- Daily points are sorted by `usage_date` before response.
|
|
115
|
+
|
|
116
|
+
### Example NDJSON Line
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
{"usage_date":"2026-01-15","resource_id":"i-0123456789abcdef0","resource_type":"instance","product_instance_type":"m6i.large","product_volume_type":"","total_cost":12.34,"usage_amount":24}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Appendix: Example Athena UNLOAD Query
|
|
123
|
+
|
|
124
|
+
Use this only as a reference implementation. Any pipeline is valid if it produces
|
|
125
|
+
data that matches the Input Data Contract above.
|
|
126
|
+
|
|
127
|
+
Replace placeholders before use:
|
|
128
|
+
|
|
129
|
+
- `<cur_database>`, `<cur_table>`
|
|
130
|
+
- `<year>`, `<month>`
|
|
131
|
+
- `<bucket>`, `<environment>`, `<year-month>`, `<run-timestamp>`
|
|
132
|
+
|
|
133
|
+
```sql
|
|
134
|
+
UNLOAD (
|
|
135
|
+
SELECT
|
|
136
|
+
usage_date,
|
|
137
|
+
resource_id,
|
|
138
|
+
resource_type,
|
|
139
|
+
product_instance_type,
|
|
140
|
+
product_volume_type,
|
|
141
|
+
SUM(total_cost) AS total_cost,
|
|
142
|
+
SUM(usage_amount) AS usage_amount
|
|
143
|
+
FROM (
|
|
144
|
+
SELECT
|
|
145
|
+
DATE(line_item_usage_start_date) AS usage_date,
|
|
146
|
+
line_item_resource_id AS resource_id,
|
|
147
|
+
'instance' AS resource_type,
|
|
148
|
+
product_instance_type AS product_instance_type,
|
|
149
|
+
'' AS product_volume_type,
|
|
150
|
+
SUM(line_item_unblended_cost) AS total_cost,
|
|
151
|
+
SUM(line_item_usage_amount) AS usage_amount
|
|
152
|
+
FROM <cur_database>.<cur_table>
|
|
153
|
+
WHERE
|
|
154
|
+
line_item_product_code = 'AmazonEC2'
|
|
155
|
+
AND product_product_family = 'Compute Instance'
|
|
156
|
+
AND line_item_line_item_type IN ('Usage', 'DiscountedUsage', 'SavingsPlanCoveredUsage')
|
|
157
|
+
AND year = '<year>'
|
|
158
|
+
AND month = '<month>'
|
|
159
|
+
AND line_item_resource_id IS NOT NULL
|
|
160
|
+
GROUP BY
|
|
161
|
+
DATE(line_item_usage_start_date),
|
|
162
|
+
line_item_resource_id,
|
|
163
|
+
product_instance_type
|
|
164
|
+
|
|
165
|
+
UNION ALL
|
|
166
|
+
|
|
167
|
+
SELECT
|
|
168
|
+
DATE(line_item_usage_start_date) AS usage_date,
|
|
169
|
+
line_item_resource_id AS resource_id,
|
|
170
|
+
CASE
|
|
171
|
+
WHEN product_product_family = 'Storage Snapshot' THEN 'snapshot'
|
|
172
|
+
ELSE 'volume'
|
|
173
|
+
END AS resource_type,
|
|
174
|
+
'' AS product_instance_type,
|
|
175
|
+
product_volume_type AS product_volume_type,
|
|
176
|
+
SUM(line_item_unblended_cost) AS total_cost,
|
|
177
|
+
SUM(line_item_usage_amount) AS usage_amount
|
|
178
|
+
FROM <cur_database>.<cur_table>
|
|
179
|
+
WHERE
|
|
180
|
+
line_item_product_code = 'AmazonEC2'
|
|
181
|
+
AND line_item_line_item_type = 'Usage'
|
|
182
|
+
AND year = '<year>'
|
|
183
|
+
AND month = '<month>'
|
|
184
|
+
AND product_product_family IN ('Storage', 'Storage Snapshot')
|
|
185
|
+
AND line_item_resource_id IS NOT NULL
|
|
186
|
+
GROUP BY
|
|
187
|
+
DATE(line_item_usage_start_date),
|
|
188
|
+
line_item_resource_id,
|
|
189
|
+
product_product_family,
|
|
190
|
+
product_volume_type
|
|
191
|
+
|
|
192
|
+
UNION ALL
|
|
193
|
+
|
|
194
|
+
SELECT
|
|
195
|
+
DATE(line_item_usage_start_date) AS usage_date,
|
|
196
|
+
COALESCE(line_item_resource_id, 'Unattached-IP') AS resource_id,
|
|
197
|
+
'elastic-ip' AS resource_type,
|
|
198
|
+
'' AS product_instance_type,
|
|
199
|
+
line_item_usage_type AS product_volume_type,
|
|
200
|
+
SUM(line_item_unblended_cost) AS total_cost,
|
|
201
|
+
SUM(line_item_usage_amount) AS usage_amount
|
|
202
|
+
FROM <cur_database>.<cur_table>
|
|
203
|
+
WHERE
|
|
204
|
+
line_item_product_code = 'AmazonEC2'
|
|
205
|
+
AND line_item_line_item_type = 'Usage'
|
|
206
|
+
AND year = '<year>'
|
|
207
|
+
AND month = '<month>'
|
|
208
|
+
AND product_product_family = 'IP Address'
|
|
209
|
+
GROUP BY
|
|
210
|
+
DATE(line_item_usage_start_date),
|
|
211
|
+
line_item_resource_id,
|
|
212
|
+
line_item_usage_type
|
|
213
|
+
|
|
214
|
+
UNION ALL
|
|
215
|
+
|
|
216
|
+
SELECT
|
|
217
|
+
DATE(line_item_usage_start_date) AS usage_date,
|
|
218
|
+
line_item_resource_id AS resource_id,
|
|
219
|
+
'nat-gateway' AS resource_type,
|
|
220
|
+
'' AS product_instance_type,
|
|
221
|
+
'' AS product_volume_type,
|
|
222
|
+
SUM(line_item_unblended_cost) AS total_cost,
|
|
223
|
+
SUM(line_item_usage_amount) AS usage_amount
|
|
224
|
+
FROM <cur_database>.<cur_table>
|
|
225
|
+
WHERE
|
|
226
|
+
line_item_product_code = 'AmazonEC2'
|
|
227
|
+
AND line_item_line_item_type = 'Usage'
|
|
228
|
+
AND year = '<year>'
|
|
229
|
+
AND month = '<month>'
|
|
230
|
+
AND (line_item_resource_id LIKE 'nat-%' OR product_product_family = 'NAT Gateway')
|
|
231
|
+
AND line_item_resource_id IS NOT NULL
|
|
232
|
+
GROUP BY
|
|
233
|
+
DATE(line_item_usage_start_date),
|
|
234
|
+
line_item_resource_id
|
|
235
|
+
|
|
236
|
+
UNION ALL
|
|
237
|
+
|
|
238
|
+
SELECT
|
|
239
|
+
DATE(line_item_usage_start_date) AS usage_date,
|
|
240
|
+
COALESCE(line_item_resource_id, 'Data-Transfer') AS resource_id,
|
|
241
|
+
'data-transfer' AS resource_type,
|
|
242
|
+
'' AS product_instance_type,
|
|
243
|
+
CASE
|
|
244
|
+
WHEN COUNT(DISTINCT product_transfer_type) > 1 THEN 'Mixed'
|
|
245
|
+
ELSE MAX(product_transfer_type)
|
|
246
|
+
END AS product_volume_type,
|
|
247
|
+
SUM(line_item_unblended_cost) AS total_cost,
|
|
248
|
+
SUM(line_item_usage_amount) AS usage_amount
|
|
249
|
+
FROM <cur_database>.<cur_table>
|
|
250
|
+
WHERE
|
|
251
|
+
line_item_product_code = 'AmazonEC2'
|
|
252
|
+
AND line_item_line_item_type = 'Usage'
|
|
253
|
+
AND year = '<year>'
|
|
254
|
+
AND month = '<month>'
|
|
255
|
+
AND product_product_family = 'Data Transfer'
|
|
256
|
+
GROUP BY
|
|
257
|
+
DATE(line_item_usage_start_date),
|
|
258
|
+
line_item_resource_id
|
|
259
|
+
|
|
260
|
+
UNION ALL
|
|
261
|
+
|
|
262
|
+
SELECT
|
|
263
|
+
DATE(line_item_usage_start_date) AS usage_date,
|
|
264
|
+
COALESCE(line_item_resource_id, 'VPC-Peering') AS resource_id,
|
|
265
|
+
'vpc' AS resource_type,
|
|
266
|
+
'' AS product_instance_type,
|
|
267
|
+
CASE
|
|
268
|
+
WHEN COUNT(DISTINCT product_product_family) > 1 THEN 'Mixed'
|
|
269
|
+
ELSE MAX(product_product_family)
|
|
270
|
+
END AS product_volume_type,
|
|
271
|
+
SUM(line_item_unblended_cost) AS total_cost,
|
|
272
|
+
SUM(line_item_usage_amount) AS usage_amount
|
|
273
|
+
FROM <cur_database>.<cur_table>
|
|
274
|
+
WHERE
|
|
275
|
+
line_item_product_code = 'AmazonEC2'
|
|
276
|
+
AND line_item_line_item_type = 'Usage'
|
|
277
|
+
AND year = '<year>'
|
|
278
|
+
AND month = '<month>'
|
|
279
|
+
AND (
|
|
280
|
+
line_item_resource_id LIKE 'vpce-%'
|
|
281
|
+
OR product_product_family IN ('VPC Peering', 'VpcEndpoint')
|
|
282
|
+
)
|
|
283
|
+
GROUP BY
|
|
284
|
+
DATE(line_item_usage_start_date),
|
|
285
|
+
line_item_resource_id
|
|
286
|
+
)
|
|
287
|
+
GROUP BY
|
|
288
|
+
usage_date,
|
|
289
|
+
resource_id,
|
|
290
|
+
resource_type,
|
|
291
|
+
product_instance_type,
|
|
292
|
+
product_volume_type
|
|
293
|
+
)
|
|
294
|
+
TO 's3://<bucket>/<environment>/daily/<year-month>/run=<run-timestamp>/'
|
|
295
|
+
WITH (
|
|
296
|
+
format = 'JSON',
|
|
297
|
+
compression = 'NONE'
|
|
298
|
+
);
|
|
299
|
+
```
|
package/config.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export interface Config {
|
|
2
|
+
costInsights?: {
|
|
3
|
+
/**
|
|
4
|
+
* Environment ids exposed to the UI and API.
|
|
5
|
+
* @example ['dev', 'stg', 'prod']
|
|
6
|
+
*/
|
|
7
|
+
environments?: string[];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Default environment used when query param is omitted.
|
|
11
|
+
* Must exist in `costInsights.environments`.
|
|
12
|
+
* @example 'prod'
|
|
13
|
+
*/
|
|
14
|
+
defaultEnvironment?: string;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* HTTP auth behavior for cost-insights backend routes.
|
|
18
|
+
*/
|
|
19
|
+
auth?: {
|
|
20
|
+
/**
|
|
21
|
+
* Allow unauthenticated access.
|
|
22
|
+
* Keep false by default for safer deployments.
|
|
23
|
+
* @default false
|
|
24
|
+
*/
|
|
25
|
+
allowUnauthenticated?: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
s3?: {
|
|
29
|
+
/**
|
|
30
|
+
* AWS region.
|
|
31
|
+
* @example 'ap-northeast-2'
|
|
32
|
+
*/
|
|
33
|
+
region: string;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* CUR result bucket name.
|
|
37
|
+
*/
|
|
38
|
+
bucket: string;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Optional AWS profile for local/SSO usage.
|
|
42
|
+
* If omitted, default credential chain is used.
|
|
43
|
+
*/
|
|
44
|
+
profile?: string;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Prefix template for daily files.
|
|
48
|
+
* Supported tokens: {environment}, {yearMonth}, {year}, {month}
|
|
49
|
+
* @default '{environment}/daily/{yearMonth}/'
|
|
50
|
+
*/
|
|
51
|
+
dailyPrefixTemplate?: string;
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.cjs.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var backendPluginApi = require('@backstage/backend-plugin-api');
|
|
4
|
+
var router = require('./service/router.cjs.js');
|
|
5
|
+
|
|
6
|
+
const costInsightsPlugin = backendPluginApi.createBackendPlugin({
|
|
7
|
+
pluginId: "cost-insights",
|
|
8
|
+
register(env) {
|
|
9
|
+
env.registerInit({
|
|
10
|
+
deps: {
|
|
11
|
+
logger: backendPluginApi.coreServices.logger,
|
|
12
|
+
config: backendPluginApi.coreServices.rootConfig,
|
|
13
|
+
httpRouter: backendPluginApi.coreServices.httpRouter
|
|
14
|
+
},
|
|
15
|
+
async init({ logger, config, httpRouter }) {
|
|
16
|
+
const router$1 = await router.createRouter({ logger, config });
|
|
17
|
+
httpRouter.use(router$1);
|
|
18
|
+
const costInsightsConfig = config.getOptionalConfig("costInsights");
|
|
19
|
+
const allowUnauthenticated = costInsightsConfig?.getOptionalBoolean("auth.allowUnauthenticated") ?? false;
|
|
20
|
+
httpRouter.addAuthPolicy({
|
|
21
|
+
path: "/",
|
|
22
|
+
allow: allowUnauthenticated ? "unauthenticated" : "user-cookie"
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
exports.costInsightsPlugin = costInsightsPlugin;
|
|
30
|
+
//# sourceMappingURL=plugin.cjs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugin.cjs.js","sources":["../src/plugin.ts"],"sourcesContent":["import {\n coreServices,\n createBackendPlugin,\n} from '@backstage/backend-plugin-api';\nimport { createRouter } from './service/router';\n\nexport const costInsightsPlugin = createBackendPlugin({\n pluginId: 'cost-insights',\n register(env) {\n env.registerInit({\n deps: {\n logger: coreServices.logger,\n config: coreServices.rootConfig,\n httpRouter: coreServices.httpRouter,\n },\n async init({ logger, config, httpRouter }) {\n const router = await createRouter({ logger, config });\n httpRouter.use(router);\n\n const costInsightsConfig = config.getOptionalConfig('costInsights');\n const allowUnauthenticated =\n costInsightsConfig?.getOptionalBoolean('auth.allowUnauthenticated') ??\n false;\n\n httpRouter.addAuthPolicy({\n path: '/',\n allow: allowUnauthenticated ? 'unauthenticated' : 'user-cookie',\n });\n },\n });\n },\n});\n"],"names":["createBackendPlugin","coreServices","router","createRouter"],"mappings":";;;;;AAMO,MAAM,qBAAqBA,oCAAA,CAAoB;AAAA,EACpD,QAAA,EAAU,eAAA;AAAA,EACV,SAAS,GAAA,EAAK;AACZ,IAAA,GAAA,CAAI,YAAA,CAAa;AAAA,MACf,IAAA,EAAM;AAAA,QACJ,QAAQC,6BAAA,CAAa,MAAA;AAAA,QACrB,QAAQA,6BAAA,CAAa,UAAA;AAAA,QACrB,YAAYA,6BAAA,CAAa;AAAA,OAC3B;AAAA,MACA,MAAM,IAAA,CAAK,EAAE,MAAA,EAAQ,MAAA,EAAQ,YAAW,EAAG;AACzC,QAAA,MAAMC,WAAS,MAAMC,mBAAA,CAAa,EAAE,MAAA,EAAQ,QAAQ,CAAA;AACpD,QAAA,UAAA,CAAW,IAAID,QAAM,CAAA;AAErB,QAAA,MAAM,kBAAA,GAAqB,MAAA,CAAO,iBAAA,CAAkB,cAAc,CAAA;AAClE,QAAA,MAAM,oBAAA,GACJ,kBAAA,EAAoB,kBAAA,CAAmB,2BAA2B,CAAA,IAClE,KAAA;AAEF,QAAA,UAAA,CAAW,aAAA,CAAc;AAAA,UACvB,IAAA,EAAM,GAAA;AAAA,UACN,KAAA,EAAO,uBAAuB,iBAAA,GAAoB;AAAA,SACnD,CAAA;AAAA,MACH;AAAA,KACD,CAAA;AAAA,EACH;AACF,CAAC;;;;"}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var clientS3 = require('@aws-sdk/client-s3');
|
|
4
|
+
var credentialProviders = require('@aws-sdk/credential-providers');
|
|
5
|
+
|
|
6
|
+
function streamToString(stream) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const chunks = [];
|
|
9
|
+
stream.on("data", (chunk) => chunks.push(chunk));
|
|
10
|
+
stream.on("error", reject);
|
|
11
|
+
stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
async function findLatestRunPrefix(params) {
|
|
15
|
+
const { s3, bucket, monthPrefix } = params;
|
|
16
|
+
const res = await s3.send(
|
|
17
|
+
new clientS3.ListObjectsV2Command({
|
|
18
|
+
Bucket: bucket,
|
|
19
|
+
Prefix: monthPrefix,
|
|
20
|
+
Delimiter: "/"
|
|
21
|
+
// List "folders" as CommonPrefixes
|
|
22
|
+
})
|
|
23
|
+
);
|
|
24
|
+
const runs = (res.CommonPrefixes ?? []).map((p) => p.Prefix).filter((p) => !!p).filter((p) => p.includes("run="));
|
|
25
|
+
if (runs.length === 0) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`No run prefixes found under: s3://${bucket}/${monthPrefix}`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
runs.sort();
|
|
31
|
+
return runs[runs.length - 1];
|
|
32
|
+
}
|
|
33
|
+
async function findDataFileKeys(params) {
|
|
34
|
+
const { s3, bucket, runPrefix } = params;
|
|
35
|
+
const res = await s3.send(
|
|
36
|
+
new clientS3.ListObjectsV2Command({
|
|
37
|
+
Bucket: bucket,
|
|
38
|
+
Prefix: runPrefix
|
|
39
|
+
})
|
|
40
|
+
);
|
|
41
|
+
const dataFiles = (res.Contents ?? []).map((o) => o.Key).filter((k) => !!k).filter((k) => !k.endsWith("-manifest.csv")).filter((k) => !k.endsWith(".metadata")).filter((k) => !k.endsWith("/")).sort();
|
|
42
|
+
if (dataFiles.length === 0) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`No data files found under: s3://${bucket}/${runPrefix}`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return dataFiles;
|
|
48
|
+
}
|
|
49
|
+
function parseNdjsonDailyCostRows(ndjson) {
|
|
50
|
+
const rows = [];
|
|
51
|
+
for (const line of ndjson.split(/\r?\n/)) {
|
|
52
|
+
const trimmed = line.trim();
|
|
53
|
+
if (!trimmed) continue;
|
|
54
|
+
const obj = JSON.parse(trimmed);
|
|
55
|
+
const totalCost = Number(obj.total_cost);
|
|
56
|
+
const usageAmount = Number(obj.usage_amount);
|
|
57
|
+
if (!Number.isFinite(totalCost) || !Number.isFinite(usageAmount)) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
rows.push({
|
|
61
|
+
usage_date: obj.usage_date,
|
|
62
|
+
resource_id: obj.resource_id,
|
|
63
|
+
resource_type: obj.resource_type,
|
|
64
|
+
product_instance_type: obj.product_instance_type ?? "",
|
|
65
|
+
product_volume_type: obj.product_volume_type ?? "",
|
|
66
|
+
total_cost: totalCost,
|
|
67
|
+
usage_amount: usageAmount
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return rows;
|
|
71
|
+
}
|
|
72
|
+
function mapResourceType(s3Type) {
|
|
73
|
+
const normalized = s3Type.toLowerCase();
|
|
74
|
+
if (normalized.includes("instance") || normalized.includes("compute"))
|
|
75
|
+
return "instance";
|
|
76
|
+
if (normalized.includes("elastic-ip") || normalized.includes("ip address"))
|
|
77
|
+
return "elastic-ip";
|
|
78
|
+
if (normalized.includes("snapshot")) return "snapshot";
|
|
79
|
+
if (normalized.includes("volume") || normalized.includes("storage"))
|
|
80
|
+
return "volume";
|
|
81
|
+
if (normalized.includes("nat")) return "nat-gateway";
|
|
82
|
+
if (normalized.includes("transfer")) return "data-transfer";
|
|
83
|
+
if (normalized.includes("vpc") || normalized.includes("endpoint"))
|
|
84
|
+
return "vpc";
|
|
85
|
+
return "other";
|
|
86
|
+
}
|
|
87
|
+
class S3CostDataClient {
|
|
88
|
+
constructor(logger, config) {
|
|
89
|
+
this.logger = logger;
|
|
90
|
+
const credentialConfig = config.profile ? {
|
|
91
|
+
region: config.region,
|
|
92
|
+
credentials: credentialProviders.fromNodeProviderChain({
|
|
93
|
+
profile: config.profile
|
|
94
|
+
})
|
|
95
|
+
} : {
|
|
96
|
+
region: config.region
|
|
97
|
+
};
|
|
98
|
+
this.client = new clientS3.S3Client(credentialConfig);
|
|
99
|
+
this.bucket = config.bucket;
|
|
100
|
+
this.dailyPrefixTemplate = config.dailyPrefixTemplate || "{environment}/daily/{yearMonth}/";
|
|
101
|
+
const authMethod = config.profile ? `SSO Profile: ${config.profile}` : "Default credential chain (IRSA/EC2 instance profile)";
|
|
102
|
+
this.logger.info(
|
|
103
|
+
`[CostInsights] S3CostDataClient initialized successfully - Auth: ${authMethod}, Bucket: ${this.bucket}, Region: ${config.region}`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
client;
|
|
107
|
+
bucket;
|
|
108
|
+
dailyPrefixTemplate;
|
|
109
|
+
buildDailyPrefix(params) {
|
|
110
|
+
const { environment, year, month } = params;
|
|
111
|
+
const paddedMonth = month.padStart(2, "0");
|
|
112
|
+
const yearMonth = `${year}-${paddedMonth}`;
|
|
113
|
+
return this.dailyPrefixTemplate.replace("{environment}", environment).replace("{yearMonth}", yearMonth).replace("{year}", year).replace("{month}", paddedMonth);
|
|
114
|
+
}
|
|
115
|
+
async queryEC2Resources(environment, year, month) {
|
|
116
|
+
const paddedMonth = month.padStart(2, "0");
|
|
117
|
+
const yearMonth = `${year}-${paddedMonth}`;
|
|
118
|
+
const monthPrefix = this.buildDailyPrefix({ environment, year, month });
|
|
119
|
+
this.logger.info(
|
|
120
|
+
`[CostInsights] Loading daily EC2 data from S3: ${environment}/${yearMonth}`
|
|
121
|
+
);
|
|
122
|
+
const runPrefix = await findLatestRunPrefix({
|
|
123
|
+
s3: this.client,
|
|
124
|
+
bucket: this.bucket,
|
|
125
|
+
monthPrefix
|
|
126
|
+
});
|
|
127
|
+
this.logger.debug(`[CostInsights] Found run prefix: ${runPrefix}`);
|
|
128
|
+
const dataFileKeys = await findDataFileKeys({
|
|
129
|
+
s3: this.client,
|
|
130
|
+
bucket: this.bucket,
|
|
131
|
+
runPrefix
|
|
132
|
+
});
|
|
133
|
+
this.logger.debug(
|
|
134
|
+
`[CostInsights] Found ${dataFileKeys.length} data files under ${runPrefix}`
|
|
135
|
+
);
|
|
136
|
+
const rowsByFile = await Promise.all(
|
|
137
|
+
dataFileKeys.map(async (dataFileKey) => {
|
|
138
|
+
const dataObj = await this.client.send(
|
|
139
|
+
new clientS3.GetObjectCommand({
|
|
140
|
+
Bucket: this.bucket,
|
|
141
|
+
Key: dataFileKey
|
|
142
|
+
})
|
|
143
|
+
);
|
|
144
|
+
if (!dataObj.Body) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`Data file has empty body: s3://${this.bucket}/${dataFileKey}`
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
const ndjson = await streamToString(dataObj.Body);
|
|
150
|
+
return parseNdjsonDailyCostRows(ndjson);
|
|
151
|
+
})
|
|
152
|
+
);
|
|
153
|
+
const dailyRows = rowsByFile.flat();
|
|
154
|
+
this.logger.info(
|
|
155
|
+
`[CostInsights] Loaded ${dailyRows.length} daily records from S3 for ${environment}/${yearMonth}`
|
|
156
|
+
);
|
|
157
|
+
const resourceMap = /* @__PURE__ */ new Map();
|
|
158
|
+
dailyRows.forEach((row) => {
|
|
159
|
+
const key = row.resource_id;
|
|
160
|
+
if (!resourceMap.has(key)) {
|
|
161
|
+
resourceMap.set(key, {
|
|
162
|
+
resourceType: row.resource_type,
|
|
163
|
+
instanceType: row.product_instance_type,
|
|
164
|
+
volumeType: row.product_volume_type,
|
|
165
|
+
totalCost: 0,
|
|
166
|
+
usageAmount: 0,
|
|
167
|
+
dailyCosts: []
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
const resource = resourceMap.get(key);
|
|
171
|
+
resource.totalCost += row.total_cost;
|
|
172
|
+
resource.usageAmount += row.usage_amount;
|
|
173
|
+
resource.dailyCosts.push({
|
|
174
|
+
date: row.usage_date,
|
|
175
|
+
cost: row.total_cost
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
const result = [];
|
|
179
|
+
resourceMap.forEach((data, resourceId) => {
|
|
180
|
+
result.push({
|
|
181
|
+
resourceId: resourceId || "Unknown",
|
|
182
|
+
resourceType: mapResourceType(data.resourceType),
|
|
183
|
+
instanceType: data.instanceType || void 0,
|
|
184
|
+
volumeType: data.volumeType || void 0,
|
|
185
|
+
totalCost: data.totalCost,
|
|
186
|
+
usageAmount: data.usageAmount,
|
|
187
|
+
dailyCosts: data.dailyCosts.sort((a, b) => a.date.localeCompare(b.date))
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
result.sort((a, b) => b.totalCost - a.totalCost);
|
|
191
|
+
this.logger.info(
|
|
192
|
+
`[CostInsights] Aggregated ${result.length} resources from ${dailyRows.length} daily records`
|
|
193
|
+
);
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Query monthly EC2 costs for a specific environment
|
|
198
|
+
* Reads daily data for each month and aggregates by resource type
|
|
199
|
+
* @param environment - legacy, dev, or prd
|
|
200
|
+
* @param numMonths - number of months to look back (default: 6)
|
|
201
|
+
*/
|
|
202
|
+
async queryMonthlyEC2Costs(environment, numMonths = 6) {
|
|
203
|
+
const now = /* @__PURE__ */ new Date();
|
|
204
|
+
const monthlyData = [];
|
|
205
|
+
this.logger.info(
|
|
206
|
+
`[CostInsights] Loading monthly costs for ${environment}, last ${numMonths} months`
|
|
207
|
+
);
|
|
208
|
+
for (let i = numMonths - 1; i >= 0; i--) {
|
|
209
|
+
const targetDate = new Date(
|
|
210
|
+
now.getFullYear(),
|
|
211
|
+
now.getMonth() - i,
|
|
212
|
+
1
|
|
213
|
+
);
|
|
214
|
+
const year = targetDate.getFullYear().toString();
|
|
215
|
+
const month = (targetDate.getMonth() + 1).toString();
|
|
216
|
+
const paddedMonth = month.padStart(2, "0");
|
|
217
|
+
const monthKey = `${year}-${paddedMonth}`;
|
|
218
|
+
try {
|
|
219
|
+
const resources = await this.queryEC2Resources(
|
|
220
|
+
environment,
|
|
221
|
+
year,
|
|
222
|
+
month
|
|
223
|
+
);
|
|
224
|
+
const monthData = {
|
|
225
|
+
month: monthKey,
|
|
226
|
+
instances: 0,
|
|
227
|
+
volume: 0,
|
|
228
|
+
elasticIp: 0,
|
|
229
|
+
natGateway: 0,
|
|
230
|
+
dataTransfer: 0,
|
|
231
|
+
vpc: 0,
|
|
232
|
+
total: 0
|
|
233
|
+
};
|
|
234
|
+
resources.forEach((resource) => {
|
|
235
|
+
switch (resource.resourceType) {
|
|
236
|
+
case "instance":
|
|
237
|
+
monthData.instances += resource.totalCost;
|
|
238
|
+
break;
|
|
239
|
+
case "volume":
|
|
240
|
+
case "snapshot":
|
|
241
|
+
monthData.volume += resource.totalCost;
|
|
242
|
+
break;
|
|
243
|
+
case "elastic-ip":
|
|
244
|
+
monthData.elasticIp += resource.totalCost;
|
|
245
|
+
break;
|
|
246
|
+
case "nat-gateway":
|
|
247
|
+
monthData.natGateway += resource.totalCost;
|
|
248
|
+
break;
|
|
249
|
+
case "data-transfer":
|
|
250
|
+
monthData.dataTransfer += resource.totalCost;
|
|
251
|
+
break;
|
|
252
|
+
case "vpc":
|
|
253
|
+
monthData.vpc += resource.totalCost;
|
|
254
|
+
break;
|
|
255
|
+
default:
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
monthData.total += resource.totalCost;
|
|
259
|
+
});
|
|
260
|
+
monthlyData.push(monthData);
|
|
261
|
+
this.logger.debug(
|
|
262
|
+
`[CostInsights] Monthly data for ${monthKey}: $${monthData.total.toFixed(2)}`
|
|
263
|
+
);
|
|
264
|
+
} catch (error) {
|
|
265
|
+
this.logger.warn(
|
|
266
|
+
`[CostInsights] Failed to load data for ${environment}/${monthKey}: ${error}`
|
|
267
|
+
);
|
|
268
|
+
monthlyData.push({
|
|
269
|
+
month: monthKey,
|
|
270
|
+
instances: 0,
|
|
271
|
+
volume: 0,
|
|
272
|
+
elasticIp: 0,
|
|
273
|
+
natGateway: 0,
|
|
274
|
+
dataTransfer: 0,
|
|
275
|
+
vpc: 0,
|
|
276
|
+
total: 0
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return monthlyData;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
exports.S3CostDataClient = S3CostDataClient;
|
|
285
|
+
//# sourceMappingURL=S3CostDataClient.cjs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"S3CostDataClient.cjs.js","sources":["../../src/service/S3CostDataClient.ts"],"sourcesContent":["import {\n S3Client,\n ListObjectsV2Command,\n GetObjectCommand,\n} from '@aws-sdk/client-s3';\nimport { fromNodeProviderChain } from '@aws-sdk/credential-providers';\nimport type { LoggerService } from '@backstage/backend-plugin-api';\nimport { Readable } from 'stream';\n\nexport interface EC2ResourceData {\n resourceId: string;\n resourceType:\n | 'instance'\n | 'elastic-ip'\n | 'other'\n | 'snapshot'\n | 'volume'\n | 'nat-gateway'\n | 'data-transfer'\n | 'vpc';\n instanceType?: string;\n volumeType?: string;\n totalCost: number;\n usageAmount: number;\n dailyCosts: Array<{\n date: string;\n cost: number;\n }>;\n}\n\nexport interface MonthlyCostData {\n month: string;\n instances: number;\n volume: number;\n elasticIp: number;\n natGateway: number;\n dataTransfer: number;\n vpc: number;\n total: number;\n}\n\ninterface S3DailyCostRow {\n usage_date: string;\n resource_id: string;\n resource_type: string;\n product_instance_type: string;\n product_volume_type: string;\n total_cost: number;\n usage_amount: number;\n}\n\n/**\n * Convert S3 readable stream to string\n */\nfunction streamToString(stream: Readable): Promise<string> {\n return new Promise((resolve, reject) => {\n const chunks: Buffer[] = [];\n stream.on('data', (chunk: Buffer) => chunks.push(chunk));\n stream.on('error', reject);\n stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));\n });\n}\n\n/**\n * Find the latest run prefix under a month prefix\n * Example: legacy/daily/2026-01/ -> legacy/daily/2026-01/run=2026-01-16T11-14-00/\n */\nasync function findLatestRunPrefix(params: {\n s3: S3Client;\n bucket: string;\n monthPrefix: string;\n}): Promise<string> {\n const { s3, bucket, monthPrefix } = params;\n\n const res = await s3.send(\n new ListObjectsV2Command({\n Bucket: bucket,\n Prefix: monthPrefix,\n Delimiter: '/', // List \"folders\" as CommonPrefixes\n }),\n );\n\n const runs = (res.CommonPrefixes ?? [])\n .map(p => p.Prefix)\n .filter((p): p is string => !!p)\n .filter(p => p.includes('run=')); // Only run=YYYY-MM-DDTHH-mm-ss/\n\n if (runs.length === 0) {\n throw new Error(\n `No run prefixes found under: s3://${bucket}/${monthPrefix}`,\n );\n }\n\n runs.sort();\n return runs[runs.length - 1];\n}\n\n/**\n * Find data files under a run prefix (without manifest)\n * Returns all data files found\n */\nasync function findDataFileKeys(params: {\n s3: S3Client;\n bucket: string;\n runPrefix: string;\n}): Promise<string[]> {\n const { s3, bucket, runPrefix } = params;\n\n const res = await s3.send(\n new ListObjectsV2Command({\n Bucket: bucket,\n Prefix: runPrefix,\n }),\n );\n\n const dataFiles = (res.Contents ?? [])\n .map(o => o.Key)\n .filter((k): k is string => !!k)\n .filter(k => !k.endsWith('-manifest.csv'))\n .filter(k => !k.endsWith('.metadata'))\n .filter(k => !k.endsWith('/'))\n .sort();\n\n if (dataFiles.length === 0) {\n throw new Error(\n `No data files found under: s3://${bucket}/${runPrefix}`,\n );\n }\n\n return dataFiles;\n}\n\n/**\n * Parse NDJSON (JSON Lines) to S3DailyCostRow array\n */\nfunction parseNdjsonDailyCostRows(ndjson: string): S3DailyCostRow[] {\n const rows: S3DailyCostRow[] = [];\n\n for (const line of ndjson.split(/\\r?\\n/)) {\n const trimmed = line.trim();\n if (!trimmed) continue;\n\n const obj = JSON.parse(trimmed);\n\n const totalCost = Number(obj.total_cost);\n const usageAmount = Number(obj.usage_amount);\n if (!Number.isFinite(totalCost) || !Number.isFinite(usageAmount)) {\n continue;\n }\n\n rows.push({\n usage_date: obj.usage_date,\n resource_id: obj.resource_id,\n resource_type: obj.resource_type,\n product_instance_type: obj.product_instance_type ?? '',\n product_volume_type: obj.product_volume_type ?? '',\n total_cost: totalCost,\n usage_amount: usageAmount,\n });\n }\n\n return rows;\n}\n\n/**\n * Map S3 resource_type to EC2ResourceData resourceType\n */\nfunction mapResourceType(s3Type: string): EC2ResourceData['resourceType'] {\n const normalized = s3Type.toLowerCase();\n\n if (normalized.includes('instance') || normalized.includes('compute'))\n return 'instance';\n if (normalized.includes('elastic-ip') || normalized.includes('ip address'))\n return 'elastic-ip';\n if (normalized.includes('snapshot')) return 'snapshot';\n if (normalized.includes('volume') || normalized.includes('storage'))\n return 'volume';\n if (normalized.includes('nat')) return 'nat-gateway';\n if (normalized.includes('transfer')) return 'data-transfer';\n if (normalized.includes('vpc') || normalized.includes('endpoint'))\n return 'vpc';\n\n return 'other';\n}\n\nexport class S3CostDataClient {\n private client: S3Client;\n private bucket: string;\n private dailyPrefixTemplate: string;\n\n constructor(\n private readonly logger: LoggerService,\n config: {\n region: string;\n bucket: string;\n profile?: string;\n dailyPrefixTemplate?: string;\n },\n ) {\n const credentialConfig = config.profile\n ? {\n region: config.region,\n credentials: fromNodeProviderChain({\n profile: config.profile,\n }),\n }\n : {\n region: config.region,\n };\n\n this.client = new S3Client(credentialConfig);\n this.bucket = config.bucket;\n this.dailyPrefixTemplate =\n config.dailyPrefixTemplate || '{environment}/daily/{yearMonth}/';\n\n const authMethod = config.profile\n ? `SSO Profile: ${config.profile}`\n : 'Default credential chain (IRSA/EC2 instance profile)';\n\n this.logger.info(\n `[CostInsights] S3CostDataClient initialized successfully - Auth: ${authMethod}, Bucket: ${this.bucket}, Region: ${config.region}`,\n );\n }\n\n private buildDailyPrefix(params: {\n environment: string;\n year: string;\n month: string;\n }): string {\n const { environment, year, month } = params;\n const paddedMonth = month.padStart(2, '0');\n const yearMonth = `${year}-${paddedMonth}`;\n\n return this.dailyPrefixTemplate\n .replace('{environment}', environment)\n .replace('{yearMonth}', yearMonth)\n .replace('{year}', year)\n .replace('{month}', paddedMonth);\n }\n\n async queryEC2Resources(\n environment: string,\n year: string,\n month: string,\n ): Promise<EC2ResourceData[]> {\n const paddedMonth = month.padStart(2, '0');\n const yearMonth = `${year}-${paddedMonth}`;\n const monthPrefix = this.buildDailyPrefix({ environment, year, month });\n\n this.logger.info(\n `[CostInsights] Loading daily EC2 data from S3: ${environment}/${yearMonth}`,\n );\n\n const runPrefix = await findLatestRunPrefix({\n s3: this.client,\n bucket: this.bucket,\n monthPrefix,\n });\n\n this.logger.debug(`[CostInsights] Found run prefix: ${runPrefix}`);\n\n const dataFileKeys = await findDataFileKeys({\n s3: this.client,\n bucket: this.bucket,\n runPrefix,\n });\n\n this.logger.debug(\n `[CostInsights] Found ${dataFileKeys.length} data files under ${runPrefix}`,\n );\n\n const rowsByFile = await Promise.all(\n dataFileKeys.map(async dataFileKey => {\n const dataObj = await this.client.send(\n new GetObjectCommand({\n Bucket: this.bucket,\n Key: dataFileKey,\n }),\n );\n\n if (!dataObj.Body) {\n throw new Error(\n `Data file has empty body: s3://${this.bucket}/${dataFileKey}`,\n );\n }\n\n const ndjson = await streamToString(dataObj.Body as Readable);\n return parseNdjsonDailyCostRows(ndjson);\n }),\n );\n const dailyRows = rowsByFile.flat();\n\n this.logger.info(\n `[CostInsights] Loaded ${dailyRows.length} daily records from S3 for ${environment}/${yearMonth}`,\n );\n\n const resourceMap = new Map<string, {\n resourceType: string;\n instanceType: string;\n volumeType: string;\n totalCost: number;\n usageAmount: number;\n dailyCosts: Array<{ date: string; cost: number }>;\n }>();\n\n dailyRows.forEach(row => {\n const key = row.resource_id;\n\n if (!resourceMap.has(key)) {\n resourceMap.set(key, {\n resourceType: row.resource_type,\n instanceType: row.product_instance_type,\n volumeType: row.product_volume_type,\n totalCost: 0,\n usageAmount: 0,\n dailyCosts: [],\n });\n }\n\n const resource = resourceMap.get(key)!;\n resource.totalCost += row.total_cost;\n resource.usageAmount += row.usage_amount;\n resource.dailyCosts.push({\n date: row.usage_date,\n cost: row.total_cost,\n });\n });\n\n const result: EC2ResourceData[] = [];\n\n resourceMap.forEach((data, resourceId) => {\n result.push({\n resourceId: resourceId || 'Unknown',\n resourceType: mapResourceType(data.resourceType),\n instanceType: data.instanceType || undefined,\n volumeType: data.volumeType || undefined,\n totalCost: data.totalCost,\n usageAmount: data.usageAmount,\n dailyCosts: data.dailyCosts.sort((a, b) => a.date.localeCompare(b.date)),\n });\n });\n\n result.sort((a, b) => b.totalCost - a.totalCost);\n\n this.logger.info(\n `[CostInsights] Aggregated ${result.length} resources from ${dailyRows.length} daily records`,\n );\n\n return result;\n }\n\n /**\n * Query monthly EC2 costs for a specific environment\n * Reads daily data for each month and aggregates by resource type\n * @param environment - legacy, dev, or prd\n * @param numMonths - number of months to look back (default: 6)\n */\n async queryMonthlyEC2Costs(\n environment: string,\n numMonths: number = 6,\n ): Promise<MonthlyCostData[]> {\n const now = new Date();\n const monthlyData: MonthlyCostData[] = [];\n\n this.logger.info(\n `[CostInsights] Loading monthly costs for ${environment}, last ${numMonths} months`,\n );\n\n for (let i = numMonths - 1; i >= 0; i--) {\n const targetDate = new Date(\n now.getFullYear(),\n now.getMonth() - i,\n 1,\n );\n const year = targetDate.getFullYear().toString();\n const month = (targetDate.getMonth() + 1).toString();\n const paddedMonth = month.padStart(2, '0');\n const monthKey = `${year}-${paddedMonth}`;\n\n try {\n const resources = await this.queryEC2Resources(\n environment,\n year,\n month,\n );\n\n const monthData: MonthlyCostData = {\n month: monthKey,\n instances: 0,\n volume: 0,\n elasticIp: 0,\n natGateway: 0,\n dataTransfer: 0,\n vpc: 0,\n total: 0,\n };\n\n resources.forEach(resource => {\n switch (resource.resourceType) {\n case 'instance':\n monthData.instances += resource.totalCost;\n break;\n case 'volume':\n case 'snapshot':\n monthData.volume += resource.totalCost;\n break;\n case 'elastic-ip':\n monthData.elasticIp += resource.totalCost;\n break;\n case 'nat-gateway':\n monthData.natGateway += resource.totalCost;\n break;\n case 'data-transfer':\n monthData.dataTransfer += resource.totalCost;\n break;\n case 'vpc':\n monthData.vpc += resource.totalCost;\n break;\n default:\n break;\n }\n monthData.total += resource.totalCost;\n });\n\n monthlyData.push(monthData);\n\n this.logger.debug(\n `[CostInsights] Monthly data for ${monthKey}: $${monthData.total.toFixed(2)}`,\n );\n } catch (error) {\n this.logger.warn(\n `[CostInsights] Failed to load data for ${environment}/${monthKey}: ${error}`,\n );\n monthlyData.push({\n month: monthKey,\n instances: 0,\n volume: 0,\n elasticIp: 0,\n natGateway: 0,\n dataTransfer: 0,\n vpc: 0,\n total: 0,\n });\n }\n }\n\n return monthlyData;\n }\n}\n"],"names":["ListObjectsV2Command","fromNodeProviderChain","S3Client","GetObjectCommand"],"mappings":"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"}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var luxon = require('luxon');
|
|
4
|
+
|
|
5
|
+
const DEFAULT_DATE_FORMAT = "yyyy-LL-dd";
|
|
6
|
+
class S3CostInsightsClient {
|
|
7
|
+
constructor(s3Client, logger) {
|
|
8
|
+
this.s3Client = s3Client;
|
|
9
|
+
this.logger = logger;
|
|
10
|
+
}
|
|
11
|
+
async getLastCompleteBillingDate() {
|
|
12
|
+
return luxon.DateTime.now().minus({ days: 1 }).toFormat(DEFAULT_DATE_FORMAT);
|
|
13
|
+
}
|
|
14
|
+
async getEC2Insights(intervals, environment) {
|
|
15
|
+
const [startDateStr, endDateStr] = intervals.split("/");
|
|
16
|
+
const startDate = luxon.DateTime.fromISO(startDateStr ?? "", { setZone: true });
|
|
17
|
+
const endDate = luxon.DateTime.fromISO(endDateStr ?? "", { setZone: true });
|
|
18
|
+
if (!startDate.isValid || !endDate.isValid) {
|
|
19
|
+
throw new Error(`Invalid intervals: ${intervals}`);
|
|
20
|
+
}
|
|
21
|
+
if (startDate > endDate) {
|
|
22
|
+
throw new Error(`Invalid intervals, start > end: ${intervals}`);
|
|
23
|
+
}
|
|
24
|
+
const months = [];
|
|
25
|
+
let cursor = startDate.startOf("month");
|
|
26
|
+
const last = endDate.startOf("month");
|
|
27
|
+
while (cursor <= last) {
|
|
28
|
+
months.push({
|
|
29
|
+
year: cursor.toFormat("yyyy"),
|
|
30
|
+
month: cursor.toFormat("M")
|
|
31
|
+
});
|
|
32
|
+
cursor = cursor.plus({ months: 1 });
|
|
33
|
+
}
|
|
34
|
+
this.logger.info(
|
|
35
|
+
`[CostInsights] Fetching EC2 resources for ${environment}, months=${months.length}, interval=${intervals}`
|
|
36
|
+
);
|
|
37
|
+
const monthlyResources = await Promise.all(
|
|
38
|
+
months.map(
|
|
39
|
+
({ year, month }) => this.s3Client.queryEC2Resources(environment, year, month)
|
|
40
|
+
)
|
|
41
|
+
);
|
|
42
|
+
const merged = /* @__PURE__ */ new Map();
|
|
43
|
+
for (const resources of monthlyResources) {
|
|
44
|
+
for (const resource of resources) {
|
|
45
|
+
const key = resource.resourceId || "Unknown";
|
|
46
|
+
const existing = merged.get(key);
|
|
47
|
+
if (!existing) {
|
|
48
|
+
merged.set(key, {
|
|
49
|
+
...resource,
|
|
50
|
+
dailyCosts: [...resource.dailyCosts]
|
|
51
|
+
});
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
existing.totalCost += resource.totalCost;
|
|
55
|
+
existing.usageAmount += resource.usageAmount;
|
|
56
|
+
existing.dailyCosts.push(...resource.dailyCosts);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const ec2Resources = Array.from(merged.values()).map((resource) => ({
|
|
60
|
+
...resource,
|
|
61
|
+
dailyCosts: resource.dailyCosts.sort(
|
|
62
|
+
(a, b) => a.date.localeCompare(b.date)
|
|
63
|
+
)
|
|
64
|
+
})).sort((a, b) => b.totalCost - a.totalCost);
|
|
65
|
+
const totalCost = ec2Resources.reduce((sum, r) => sum + r.totalCost, 0);
|
|
66
|
+
const monthlyData = await this.s3Client.queryMonthlyEC2Costs(
|
|
67
|
+
environment,
|
|
68
|
+
12
|
|
69
|
+
);
|
|
70
|
+
const entities = ec2Resources.map((resource) => ({
|
|
71
|
+
id: resource.resourceId || "Unknown",
|
|
72
|
+
resourceId: resource.resourceId,
|
|
73
|
+
resourceType: resource.resourceType,
|
|
74
|
+
instanceType: resource.instanceType,
|
|
75
|
+
volumeType: resource.volumeType,
|
|
76
|
+
totalCost: resource.totalCost,
|
|
77
|
+
usageAmount: resource.usageAmount,
|
|
78
|
+
dailyCosts: resource.dailyCosts,
|
|
79
|
+
entities: {},
|
|
80
|
+
aggregation: [resource.totalCost, 0],
|
|
81
|
+
change: { ratio: 0, amount: 0 }
|
|
82
|
+
}));
|
|
83
|
+
return {
|
|
84
|
+
id: "ec2",
|
|
85
|
+
entities: { ec2: entities },
|
|
86
|
+
monthlyData,
|
|
87
|
+
aggregation: [totalCost, 0],
|
|
88
|
+
change: { ratio: 0, amount: 0 }
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
exports.S3CostInsightsClient = S3CostInsightsClient;
|
|
94
|
+
//# sourceMappingURL=S3CostInsightsClient.cjs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"S3CostInsightsClient.cjs.js","sources":["../../src/service/S3CostInsightsClient.ts"],"sourcesContent":["import type { LoggerService } from '@backstage/backend-plugin-api';\nimport { DateTime } from 'luxon';\nimport type { EC2ResourceData, S3CostDataClient } from './S3CostDataClient';\n\nconst DEFAULT_DATE_FORMAT = 'yyyy-LL-dd';\n\nexport class S3CostInsightsClient {\n constructor(\n private readonly s3Client: S3CostDataClient,\n private readonly logger: LoggerService,\n ) {}\n\n async getLastCompleteBillingDate(): Promise<string> {\n return DateTime.now().minus({ days: 1 }).toFormat(DEFAULT_DATE_FORMAT);\n }\n\n async getEC2Insights(\n intervals: string,\n environment: string,\n ) {\n const [startDateStr, endDateStr] = intervals.split('/');\n const startDate = DateTime.fromISO(startDateStr ?? '', { setZone: true });\n const endDate = DateTime.fromISO(endDateStr ?? '', { setZone: true });\n\n if (!startDate.isValid || !endDate.isValid) {\n throw new Error(`Invalid intervals: ${intervals}`);\n }\n if (startDate > endDate) {\n throw new Error(`Invalid intervals, start > end: ${intervals}`);\n }\n\n const months: Array<{ year: string; month: string }> = [];\n let cursor = startDate.startOf('month');\n const last = endDate.startOf('month');\n while (cursor <= last) {\n months.push({\n year: cursor.toFormat('yyyy'),\n month: cursor.toFormat('M'),\n });\n cursor = cursor.plus({ months: 1 });\n }\n\n this.logger.info(\n `[CostInsights] Fetching EC2 resources for ${environment}, months=${months.length}, interval=${intervals}`,\n );\n\n const monthlyResources = await Promise.all(\n months.map(({ year, month }) =>\n this.s3Client.queryEC2Resources(environment, year, month),\n ),\n );\n\n const merged = new Map<string, EC2ResourceData>();\n for (const resources of monthlyResources) {\n for (const resource of resources) {\n const key = resource.resourceId || 'Unknown';\n const existing = merged.get(key);\n if (!existing) {\n merged.set(key, {\n ...resource,\n dailyCosts: [...resource.dailyCosts],\n });\n continue;\n }\n\n existing.totalCost += resource.totalCost;\n existing.usageAmount += resource.usageAmount;\n existing.dailyCosts.push(...resource.dailyCosts);\n }\n }\n\n const ec2Resources = Array.from(merged.values())\n .map(resource => ({\n ...resource,\n dailyCosts: resource.dailyCosts.sort((a, b) =>\n a.date.localeCompare(b.date),\n ),\n }))\n .sort((a, b) => b.totalCost - a.totalCost);\n\n const totalCost = ec2Resources.reduce((sum, r) => sum + r.totalCost, 0);\n\n // Always fetch 12 months for frontend flexibility (6/12 months toggle)\n const monthlyData = await this.s3Client.queryMonthlyEC2Costs(\n environment,\n 12,\n );\n\n const entities = ec2Resources.map(resource => ({\n id: resource.resourceId || 'Unknown',\n resourceId: resource.resourceId,\n resourceType: resource.resourceType,\n instanceType: resource.instanceType,\n volumeType: resource.volumeType,\n totalCost: resource.totalCost,\n usageAmount: resource.usageAmount,\n dailyCosts: resource.dailyCosts,\n entities: {},\n aggregation: [resource.totalCost, 0] as [number, number],\n change: { ratio: 0, amount: 0 },\n }));\n\n return {\n id: 'ec2',\n entities: { ec2: entities },\n monthlyData: monthlyData,\n aggregation: [totalCost, 0] as [number, number],\n change: { ratio: 0, amount: 0 },\n };\n }\n}\n"],"names":["DateTime"],"mappings":"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"}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var express = require('express');
|
|
4
|
+
var Router = require('express-promise-router');
|
|
5
|
+
var luxon = require('luxon');
|
|
6
|
+
var S3CostDataClient = require('./S3CostDataClient.cjs.js');
|
|
7
|
+
var S3CostInsightsClient = require('./S3CostInsightsClient.cjs.js');
|
|
8
|
+
|
|
9
|
+
function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e : { default: e }; }
|
|
10
|
+
|
|
11
|
+
var express__default = /*#__PURE__*/_interopDefaultCompat(express);
|
|
12
|
+
var Router__default = /*#__PURE__*/_interopDefaultCompat(Router);
|
|
13
|
+
|
|
14
|
+
async function createRouter(options) {
|
|
15
|
+
const { logger, config } = options;
|
|
16
|
+
const router = Router__default.default();
|
|
17
|
+
router.use(express__default.default.json());
|
|
18
|
+
const costInsightsConfig = config.getOptionalConfig("costInsights");
|
|
19
|
+
const s3Config = costInsightsConfig?.getOptionalConfig("s3");
|
|
20
|
+
if (!s3Config) {
|
|
21
|
+
logger.warn("[CostInsights] S3 configuration not found");
|
|
22
|
+
router.get("/health", (_, res) => {
|
|
23
|
+
res.json({ status: "ok", message: "S3 not configured" });
|
|
24
|
+
});
|
|
25
|
+
return router;
|
|
26
|
+
}
|
|
27
|
+
const environments = costInsightsConfig?.getOptionalStringArray("environments") ?? ["prd"];
|
|
28
|
+
const configuredDefaultEnvironment = costInsightsConfig?.getOptionalString("defaultEnvironment") ?? environments[0];
|
|
29
|
+
const defaultEnvironment = environments.includes(configuredDefaultEnvironment) ? configuredDefaultEnvironment : environments[0];
|
|
30
|
+
if (!environments.includes(configuredDefaultEnvironment)) {
|
|
31
|
+
logger.warn(
|
|
32
|
+
`[CostInsights] defaultEnvironment (${configuredDefaultEnvironment}) is not in environments list, fallback to ${defaultEnvironment}`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
const s3DataClient = new S3CostDataClient.S3CostDataClient(logger, {
|
|
36
|
+
region: s3Config.getString("region"),
|
|
37
|
+
bucket: s3Config.getString("bucket"),
|
|
38
|
+
profile: s3Config.getOptionalString("profile"),
|
|
39
|
+
dailyPrefixTemplate: s3Config.getOptionalString("dailyPrefixTemplate")
|
|
40
|
+
});
|
|
41
|
+
const costInsightsClient = new S3CostInsightsClient.S3CostInsightsClient(s3DataClient, logger);
|
|
42
|
+
logger.info(`[CostInsights] Initialized S3 client for environments: ${environments.join(", ")}`);
|
|
43
|
+
router.get("/health", (_, res) => {
|
|
44
|
+
res.json({ status: "ok", environments });
|
|
45
|
+
});
|
|
46
|
+
router.get("/config", (_, res) => {
|
|
47
|
+
res.json({
|
|
48
|
+
environments,
|
|
49
|
+
defaultEnvironment
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
router.get("/last-complete-date", async (req, res) => {
|
|
53
|
+
const { environment = defaultEnvironment } = req.query;
|
|
54
|
+
if (!environments.includes(environment)) {
|
|
55
|
+
res.status(400).json({ error: `Invalid environment: ${environment}` });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const date = await costInsightsClient.getLastCompleteBillingDate();
|
|
59
|
+
res.json({ date, environment });
|
|
60
|
+
});
|
|
61
|
+
router.get("/product/ec2/insights", async (req, res) => {
|
|
62
|
+
const { intervals, environment = defaultEnvironment } = req.query;
|
|
63
|
+
logger.info(`[CostInsights] GET /product/ec2/insights - intervals: ${intervals}, environment: ${environment}`);
|
|
64
|
+
if (!intervals) {
|
|
65
|
+
res.status(400).json({ error: "intervals parameter required" });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const [startStr, endStr] = intervals.split("/");
|
|
69
|
+
const start = luxon.DateTime.fromISO(startStr ?? "", { setZone: true });
|
|
70
|
+
const end = luxon.DateTime.fromISO(endStr ?? "", { setZone: true });
|
|
71
|
+
if (!start.isValid || !end.isValid) {
|
|
72
|
+
res.status(400).json({ error: "intervals must be valid ISO interval: start/end" });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (start > end) {
|
|
76
|
+
res.status(400).json({ error: "interval start must be before or equal to end" });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const monthDiff = Math.abs(
|
|
80
|
+
end.startOf("month").diff(start.startOf("month"), "months").months
|
|
81
|
+
);
|
|
82
|
+
if (monthDiff > 24) {
|
|
83
|
+
res.status(400).json({ error: "intervals range must be within 24 months" });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (!environments.includes(environment)) {
|
|
87
|
+
res.status(400).json({ error: `Invalid environment: ${environment}` });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const insights = await costInsightsClient.getEC2Insights(
|
|
91
|
+
intervals,
|
|
92
|
+
environment
|
|
93
|
+
);
|
|
94
|
+
res.json({ ...insights, environment });
|
|
95
|
+
});
|
|
96
|
+
logger.info(`[CostInsights] Router initialized successfully for environments: ${environments.join(", ")}`);
|
|
97
|
+
return router;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
exports.createRouter = createRouter;
|
|
101
|
+
//# sourceMappingURL=router.cjs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"router.cjs.js","sources":["../../src/service/router.ts"],"sourcesContent":["import type { LoggerService } from '@backstage/backend-plugin-api';\nimport type { Config } from '@backstage/config';\nimport express from 'express';\nimport Router from 'express-promise-router';\nimport { DateTime } from 'luxon';\nimport { S3CostDataClient } from './S3CostDataClient';\nimport { S3CostInsightsClient } from './S3CostInsightsClient';\n\nexport interface RouterOptions {\n logger: LoggerService;\n config: Config;\n}\n\nexport async function createRouter(\n options: RouterOptions,\n): Promise<express.Router> {\n const { logger, config } = options;\n\n const router = Router();\n router.use(express.json());\n\n const costInsightsConfig = config.getOptionalConfig('costInsights');\n const s3Config = costInsightsConfig?.getOptionalConfig('s3');\n\n if (!s3Config) {\n logger.warn('[CostInsights] S3 configuration not found');\n router.get('/health', (_, res) => {\n res.json({ status: 'ok', message: 'S3 not configured' });\n });\n return router;\n }\n\n const environments =\n costInsightsConfig?.getOptionalStringArray('environments') ?? ['prd'];\n const configuredDefaultEnvironment =\n costInsightsConfig?.getOptionalString('defaultEnvironment') ??\n environments[0];\n const defaultEnvironment = environments.includes(configuredDefaultEnvironment)\n ? configuredDefaultEnvironment\n : environments[0];\n\n if (!environments.includes(configuredDefaultEnvironment)) {\n logger.warn(\n `[CostInsights] defaultEnvironment (${configuredDefaultEnvironment}) is not in environments list, fallback to ${defaultEnvironment}`,\n );\n }\n\n const s3DataClient = new S3CostDataClient(logger, {\n region: s3Config.getString('region'),\n bucket: s3Config.getString('bucket'),\n profile: s3Config.getOptionalString('profile'),\n dailyPrefixTemplate: s3Config.getOptionalString('dailyPrefixTemplate'),\n });\n\n const costInsightsClient = new S3CostInsightsClient(s3DataClient, logger);\n\n logger.info(`[CostInsights] Initialized S3 client for environments: ${environments.join(', ')}`)\n\n router.get('/health', (_, res) => {\n res.json({ status: 'ok', environments });\n });\n\n router.get('/config', (_, res) => {\n res.json({\n environments,\n defaultEnvironment,\n });\n });\n\n router.get('/last-complete-date', async (req, res) => {\n const { environment = defaultEnvironment } = req.query as {\n environment?: string;\n };\n\n if (!environments.includes(environment)) {\n res.status(400).json({ error: `Invalid environment: ${environment}` });\n return;\n }\n\n const date = await costInsightsClient.getLastCompleteBillingDate();\n res.json({ date, environment });\n });\n\n router.get('/product/ec2/insights', async (req, res) => {\n const { intervals, environment = defaultEnvironment } = req.query as {\n intervals: string;\n environment?: string;\n };\n\n logger.info(`[CostInsights] GET /product/ec2/insights - intervals: ${intervals}, environment: ${environment}`);\n\n if (!intervals) {\n res.status(400).json({ error: 'intervals parameter required' });\n return;\n }\n\n const [startStr, endStr] = intervals.split('/');\n const start = DateTime.fromISO(startStr ?? '', { setZone: true });\n const end = DateTime.fromISO(endStr ?? '', { setZone: true });\n if (!start.isValid || !end.isValid) {\n res\n .status(400)\n .json({ error: 'intervals must be valid ISO interval: start/end' });\n return;\n }\n if (start > end) {\n res\n .status(400)\n .json({ error: 'interval start must be before or equal to end' });\n return;\n }\n const monthDiff = Math.abs(\n end.startOf('month').diff(start.startOf('month'), 'months').months,\n );\n if (monthDiff > 24) {\n res\n .status(400)\n .json({ error: 'intervals range must be within 24 months' });\n return;\n }\n\n if (!environments.includes(environment)) {\n res.status(400).json({ error: `Invalid environment: ${environment}` });\n return;\n }\n\n const insights = await costInsightsClient.getEC2Insights(\n intervals,\n environment,\n );\n res.json({ ...insights, environment });\n });\n\n logger.info(`[CostInsights] Router initialized successfully for environments: ${environments.join(', ')}`);\n\n return router;\n}\n"],"names":["Router","express","S3CostDataClient","S3CostInsightsClient","DateTime"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@letthem/backstage-plugin-aws-cost-insights-backend",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"main": "dist/index.cjs.js",
|
|
5
|
+
"types": "dist/index.d.ts",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public",
|
|
9
|
+
"main": "dist/index.cjs.js",
|
|
10
|
+
"types": "dist/index.d.ts"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "https://github.com/letthem/backstage-plugin-cost-insights.git",
|
|
15
|
+
"directory": "plugins/cost-insights-backend"
|
|
16
|
+
},
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/letthem/backstage-plugin-cost-insights/issues"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"backstage",
|
|
22
|
+
"plugin",
|
|
23
|
+
"backend",
|
|
24
|
+
"cost-insights",
|
|
25
|
+
"aws",
|
|
26
|
+
"s3",
|
|
27
|
+
"athena",
|
|
28
|
+
"finops"
|
|
29
|
+
],
|
|
30
|
+
"homepage": "https://github.com/letthem/backstage-plugin-cost-insights#readme",
|
|
31
|
+
"backstage": {
|
|
32
|
+
"role": "backend-plugin",
|
|
33
|
+
"pluginId": "aws-cost-insights",
|
|
34
|
+
"pluginPackages": [
|
|
35
|
+
"@letthem/backstage-plugin-aws-cost-insights",
|
|
36
|
+
"@letthem/backstage-plugin-aws-cost-insights-backend"
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
"sideEffects": false,
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "backstage-cli package build",
|
|
42
|
+
"lint": "backstage-cli package lint",
|
|
43
|
+
"test": "backstage-cli package test --passWithNoTests",
|
|
44
|
+
"clean": "backstage-cli package clean",
|
|
45
|
+
"prepack": "backstage-cli package prepack",
|
|
46
|
+
"postpack": "backstage-cli package postpack"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@aws-sdk/client-athena": "^3.700.0",
|
|
50
|
+
"@aws-sdk/client-s3": "^3.700.0",
|
|
51
|
+
"@aws-sdk/credential-providers": "^3.967.0",
|
|
52
|
+
"@backstage/backend-plugin-api": "^1.5.0",
|
|
53
|
+
"@backstage/config": "^1.3.6",
|
|
54
|
+
"express": "^4.17.1",
|
|
55
|
+
"express-promise-router": "^4.1.0",
|
|
56
|
+
"luxon": "^3.0.0",
|
|
57
|
+
"node-fetch": "2",
|
|
58
|
+
"winston": "^3.2.1"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@backstage/cli": "^0.29.3",
|
|
62
|
+
"@types/express": "^4.17.6",
|
|
63
|
+
"@types/luxon": "^3.4.2",
|
|
64
|
+
"@types/node": "^18"
|
|
65
|
+
},
|
|
66
|
+
"files": [
|
|
67
|
+
"dist",
|
|
68
|
+
"config.d.ts"
|
|
69
|
+
],
|
|
70
|
+
"typesVersions": {
|
|
71
|
+
"*": {
|
|
72
|
+
"index": [
|
|
73
|
+
"dist/index.d.ts"
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|