1import * as CostExplorer from "@/AWS/CostExplorer";
2import * as Lambda from "@/AWS/Lambda";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Stream from "effect/Stream";
7import { HttpServerRequest } from "effect/http/HttpServerRequest";
8import * as HttpServerResponse from "effect/http/HttpServerResponse";
9import path from "pathe";
10
11const main = path.resolve(import.meta.dirname, "handler.ts");
12
13// A well-formed-but-nonexistent anomaly id — drives the typed error path for
14// ProvideAnomalyFeedback (proving the monitor-scoped grant reaches the API).
15const NONEXISTENT_ANOMALY_ID = "00000000-0000-0000-0000-000000000000";
16
17/**
18 * First day of the month `offset` months from now (UTC), as `yyyy-MM-dd`.
19 * Cost Explorer time periods are half-open `[Start, End)` date intervals.
20 */
21const monthStart = (offset: number) =>
22 Effect.sync(() => {
23 const now = new Date();
24 return new Date(
25 Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + offset, 1),
26 )
27 .toISOString()
28 .slice(0, 10);
29 });
30
31export class CostExplorerTestFunction extends Lambda.Function<Lambda.Function>()(
32 "CostExplorerTestFunction",
33) {}
34
35export default CostExplorerTestFunction.make(
36 {
37 main,
38 functionUrl: true,
39 // Cost Explorer queries routinely take several seconds; the AWS default
40 // 3s Lambda timeout is too tight.
41 timeout: Duration.seconds(30),
42 },
43 Effect.gen(function* () {
44 // A CUSTOM monitor (the account allows only one DIMENSIONAL/SERVICE
45 // monitor, which AnomalyMonitor.test.ts may own).
46 const monitor = yield* CostExplorer.AnomalyMonitor("BindingsMonitor", {
47 monitorType: "CUSTOM",
48 monitorSpecification: {
49 Tags: { Key: "alchemy-ce-bindings", Values: ["fixture"] },
50 },
51 });
52
53 const category = yield* CostExplorer.CostCategory("BindingsCategory", {
54 rules: [
55 {
56 Value: "fixture",
57 Type: "REGULAR",
58 Rule: {
59 Tags: {
60 Key: "alchemy-ce-bindings",
61 Values: ["fixture"],
62 MatchOptions: ["EQUALS"],
63 },
64 },
65 },
66 ],
67 defaultValue: "other",
68 });
69
70 // --- account-level bindings ---
71 const getCostAndUsage = yield* CostExplorer.GetCostAndUsage();
72 const getCostAndUsageComparisons =
73 yield* CostExplorer.GetCostAndUsageComparisons();
74 const getCostAndUsageWithResources =
75 yield* CostExplorer.GetCostAndUsageWithResources();
76 const getCostComparisonDrivers =
77 yield* CostExplorer.GetCostComparisonDrivers();
78 const getApproximateUsageRecords =
79 yield* CostExplorer.GetApproximateUsageRecords();
80 const getCostForecast = yield* CostExplorer.GetCostForecast();
81 const getUsageForecast = yield* CostExplorer.GetUsageForecast();
82 const getDimensionValues = yield* CostExplorer.GetDimensionValues();
83 const getTags = yield* CostExplorer.GetTags();
84 const getCostCategories = yield* CostExplorer.GetCostCategories();
85 const getReservationCoverage = yield* CostExplorer.GetReservationCoverage();
86 const getReservationPurchaseRecommendation =
87 yield* CostExplorer.GetReservationPurchaseRecommendation();
88 const getReservationUtilization =
89 yield* CostExplorer.GetReservationUtilization();
90 const getRightsizingRecommendation =
91 yield* CostExplorer.GetRightsizingRecommendation();
92 const getSavingsPlansCoverage =
93 yield* CostExplorer.GetSavingsPlansCoverage();
94 const getSavingsPlansPurchaseRecommendation =
95 yield* CostExplorer.GetSavingsPlansPurchaseRecommendation();
96 const getSavingsPlanPurchaseRecommendationDetails =
97 yield* CostExplorer.GetSavingsPlanPurchaseRecommendationDetails();
98 const getSavingsPlansUtilization =
99 yield* CostExplorer.GetSavingsPlansUtilization();
100 const getSavingsPlansUtilizationDetails =
101 yield* CostExplorer.GetSavingsPlansUtilizationDetails();
102 const startSavingsPlansPurchaseRecommendationGeneration =
103 yield* CostExplorer.StartSavingsPlansPurchaseRecommendationGeneration();
104 const listSavingsPlansPurchaseRecommendationGeneration =
105 yield* CostExplorer.ListSavingsPlansPurchaseRecommendationGeneration();
106 const startCommitmentPurchaseAnalysis =
107 yield* CostExplorer.StartCommitmentPurchaseAnalysis();
108 const getCommitmentPurchaseAnalysis =
109 yield* CostExplorer.GetCommitmentPurchaseAnalysis();
110 const listCommitmentPurchaseAnalyses =
111 yield* CostExplorer.ListCommitmentPurchaseAnalyses();
112 const listCostAllocationTags = yield* CostExplorer.ListCostAllocationTags();
113 const updateCostAllocationTagsStatus =
114 yield* CostExplorer.UpdateCostAllocationTagsStatus();
115 const startCostAllocationTagBackfill =
116 yield* CostExplorer.StartCostAllocationTagBackfill();
117 const listCostAllocationTagBackfillHistory =
118 yield* CostExplorer.ListCostAllocationTagBackfillHistory();
119 const provideAnomalyFeedback = yield* CostExplorer.ProvideAnomalyFeedback();
120
121 // --- monitor-scoped bindings ---
122 const getAnomalies = yield* CostExplorer.GetAnomalies(monitor);
123
124 // --- cost-category-scoped bindings ---
125 const listCostCategoryResourceAssociations =
126 yield* CostExplorer.ListCostCategoryResourceAssociations(category);
127
128 // --- anomaly event source ---
129 // Anomalies cannot be forced in a test (they require days of real spend
130 // data), so this exercises the deploy-time wiring: the EventBridge rule
131 // on `aws.ce` / `Anomaly Detected` plus the Lambda invoke permission.
132 yield* CostExplorer.consumeAnomalyEvents({}, (events) =>
133 Stream.runForEach(events, (event) =>
134 Effect.log(
135 `cost anomaly ${event.detail.anomalyId} on ${event.detail.dimensionValue}`,
136 ),
137 ),
138 );
139
140 const bound = {
141 getCostAndUsage,
142 getCostAndUsageComparisons,
143 getCostAndUsageWithResources,
144 getCostComparisonDrivers,
145 getApproximateUsageRecords,
146 getCostForecast,
147 getUsageForecast,
148 getDimensionValues,
149 getTags,
150 getCostCategories,
151 getReservationCoverage,
152 getReservationPurchaseRecommendation,
153 getReservationUtilization,
154 getRightsizingRecommendation,
155 getSavingsPlansCoverage,
156 getSavingsPlansPurchaseRecommendation,
157 getSavingsPlanPurchaseRecommendationDetails,
158 getSavingsPlansUtilization,
159 getSavingsPlansUtilizationDetails,
160 startSavingsPlansPurchaseRecommendationGeneration,
161 listSavingsPlansPurchaseRecommendationGeneration,
162 startCommitmentPurchaseAnalysis,
163 getCommitmentPurchaseAnalysis,
164 listCommitmentPurchaseAnalyses,
165 listCostAllocationTags,
166 updateCostAllocationTagsStatus,
167 startCostAllocationTagBackfill,
168 listCostAllocationTagBackfillHistory,
169 getAnomalies,
170 provideAnomalyFeedback,
171 listCostCategoryResourceAssociations,
172 };
173
174 return {
175 fetch: Effect.gen(function* () {
176 const request = yield* HttpServerRequest;
177 const url = new URL(request.originalUrl);
178 const pathname = url.pathname;
179
180 if (request.method === "GET" && pathname === "/bindings") {
181 return yield* HttpServerResponse.json({
182 bound: Object.keys(bound),
183 });
184 }
185
186 if (request.method === "GET" && pathname === "/cost-and-usage") {
187 const Start = yield* monthStart(-1);
188 const End = yield* monthStart(0);
189 const result = yield* getCostAndUsage({
190 TimePeriod: { Start, End },
191 Granularity: "MONTHLY",
192 Metrics: ["UnblendedCost"],
193 }).pipe(
194 Effect.map((r) => ({
195 tag: "Ok",
196 results: (r.ResultsByTime ?? []).length,
197 amount:
198 r.ResultsByTime?.[0]?.Total?.UnblendedCost?.Amount ?? null,
199 })),
200 Effect.catchTag("DataUnavailableException", (e) =>
201 Effect.succeed({ tag: e._tag, results: 0, amount: null }),
202 ),
203 );
204 return yield* HttpServerResponse.json(result);
205 }
206
207 if (request.method === "GET" && pathname === "/dimension-values") {
208 const Start = yield* monthStart(-1);
209 const End = yield* monthStart(0);
210 const result = yield* getDimensionValues({
211 TimePeriod: { Start, End },
212 Dimension: "SERVICE",
213 }).pipe(
214 Effect.map((r) => ({
215 tag: "Ok",
216 count: (r.DimensionValues ?? []).length,
217 })),
218 Effect.catchTag("DataUnavailableException", (e) =>
219 Effect.succeed({ tag: e._tag, count: 0 }),
220 ),
221 );
222 return yield* HttpServerResponse.json(result);
223 }
224
225 if (request.method === "GET" && pathname === "/tag-keys") {
226 const Start = yield* monthStart(-1);
227 const End = yield* monthStart(0);
228 const result = yield* getTags({ TimePeriod: { Start, End } }).pipe(
229 Effect.map((r) => ({ tag: "Ok", count: (r.Tags ?? []).length })),
230 Effect.catchTag("DataUnavailableException", (e) =>
231 Effect.succeed({ tag: e._tag, count: 0 }),
232 ),
233 );
234 return yield* HttpServerResponse.json(result);
235 }
236
237 if (request.method === "GET" && pathname === "/cost-categories") {
238 const Start = yield* monthStart(-1);
239 const End = yield* monthStart(0);
240 const result = yield* getCostCategories({
241 TimePeriod: { Start, End },
242 }).pipe(
243 Effect.map((r) => ({
244 tag: "Ok",
245 names: r.CostCategoryNames ?? [],
246 })),
247 Effect.catchTag("DataUnavailableException", (e) =>
248 Effect.succeed({ tag: e._tag, names: [] as string[] }),
249 ),
250 );
251 return yield* HttpServerResponse.json(result);
252 }
253
254 if (request.method === "GET" && pathname === "/forecast") {
255 const Start = yield* monthStart(1);
256 const End = yield* monthStart(2);
257 const result = yield* getCostForecast({
258 TimePeriod: { Start, End },
259 Metric: "UNBLENDED_COST",
260 Granularity: "MONTHLY",
261 }).pipe(
262 Effect.map((r) => ({ tag: "Ok", amount: r.Total?.Amount ?? null })),
263 Effect.catchTag("DataUnavailableException", (e) =>
264 Effect.succeed({ tag: e._tag, amount: null }),
265 ),
266 );
267 return yield* HttpServerResponse.json(result);
268 }
269
270 if (request.method === "GET" && pathname === "/approximate-usage") {
271 // SERVICE-dimension estimates only support HOURLY granularity —
272 // DAILY is resource-level only and fails with ValidationException
273 // ("The estimation data cannot be generated for your input
274 // combination", verified by probe).
275 const result = yield* getApproximateUsageRecords({
276 Granularity: "HOURLY",
277 ApproximationDimension: "SERVICE",
278 }).pipe(
279 Effect.map((r) => ({ tag: "Ok", total: r.TotalRecords ?? 0 })),
280 Effect.catchTag("DataUnavailableException", (e) =>
281 Effect.succeed({ tag: e._tag, total: 0 }),
282 ),
283 );
284 return yield* HttpServerResponse.json(result);
285 }
286
287 if (request.method === "GET" && pathname === "/rightsizing") {
288 // Rightsizing recommendations are an opt-in Cost Explorer
289 // preference on the payer account. An account without the opt-in
290 // rejects with RightsizingRecommendationNotEnabled — the
291 // AccessDeniedException "opt-in only feature" rejection (verified
292 // by probe) patched into distilled as a specific typed tag. A
293 // genuine IAM gap still surfaces as AccessDeniedException and
294 // fails the route.
295 const result = yield* getRightsizingRecommendation({
296 Service: "AmazonEC2",
297 }).pipe(
298 Effect.map((r) => ({
299 tag: "Ok",
300 count: (r.RightsizingRecommendations ?? []).length,
301 })),
302 Effect.catchTag("RightsizingRecommendationNotEnabled", (e) =>
303 Effect.succeed({ tag: e._tag, count: 0 }),
304 ),
305 );
306 return yield* HttpServerResponse.json(result);
307 }
308
309 if (
310 request.method === "GET" &&
311 pathname === "/reservation-recommendation"
312 ) {
313 const result = yield* getReservationPurchaseRecommendation({
314 Service: "Amazon Elastic Compute Cloud - Compute",
315 }).pipe(
316 Effect.map((r) => ({
317 tag: "Ok",
318 count: (r.Recommendations ?? []).length,
319 })),
320 Effect.catchTag("DataUnavailableException", (e) =>
321 Effect.succeed({ tag: e._tag, count: 0 }),
322 ),
323 );
324 return yield* HttpServerResponse.json(result);
325 }
326
327 if (
328 request.method === "GET" &&
329 pathname === "/savings-plans-utilization"
330 ) {
331 const Start = yield* monthStart(-1);
332 const End = yield* monthStart(0);
333 const result = yield* getSavingsPlansUtilization({
334 TimePeriod: { Start, End },
335 }).pipe(
336 Effect.map((r) => ({
337 tag: "Ok",
338 utilization: r.Total?.Utilization?.UtilizationPercentage ?? null,
339 })),
340 Effect.catchTag("DataUnavailableException", (e) =>
341 Effect.succeed({ tag: e._tag, utilization: null }),
342 ),
343 );
344 return yield* HttpServerResponse.json(result);
345 }
346
347 if (
348 request.method === "GET" &&
349 pathname === "/recommendation-generations"
350 ) {
351 const result =
352 yield* listSavingsPlansPurchaseRecommendationGeneration().pipe(
353 Effect.map((r) => ({
354 tag: "Ok",
355 count: (r.GenerationSummaryList ?? []).length,
356 })),
357 Effect.catchTag("DataUnavailableException", (e) =>
358 Effect.succeed({ tag: e._tag, count: 0 }),
359 ),
360 );
361 return yield* HttpServerResponse.json(result);
362 }
363
364 if (request.method === "GET" && pathname === "/commitment-analyses") {
365 const result = yield* listCommitmentPurchaseAnalyses().pipe(
366 Effect.map((r) => ({
367 tag: "Ok",
368 count: (r.AnalysisSummaryList ?? []).length,
369 })),
370 Effect.catchTag("DataUnavailableException", (e) =>
371 Effect.succeed({ tag: e._tag, count: 0 }),
372 ),
373 );
374 return yield* HttpServerResponse.json(result);
375 }
376
377 if (request.method === "GET" && pathname === "/allocation-tags") {
378 const result = yield* listCostAllocationTags({ MaxResults: 100 });
379 return yield* HttpServerResponse.json({
380 count: (result.CostAllocationTags ?? []).length,
381 });
382 }
383
384 if (request.method === "GET" && pathname === "/backfill-history") {
385 const result = yield* listCostAllocationTagBackfillHistory();
386 return yield* HttpServerResponse.json({
387 count: (result.BackfillRequests ?? []).length,
388 });
389 }
390
391 if (request.method === "GET" && pathname === "/anomalies") {
392 // GetAnomalies only accepts a start date within the last 90 days;
393 // two months back exceeds that at some month ends.
394 const Start = yield* monthStart(-1);
395 const result = yield* getAnomalies({
396 DateInterval: { StartDate: Start },
397 });
398 return yield* HttpServerResponse.json({
399 count: (result.Anomalies ?? []).length,
400 });
401 }
402
403 if (
404 request.method === "GET" &&
405 pathname === "/anomaly-feedback-invalid"
406 ) {
407 // Exercises the account-level grant + the typed error path — the
408 // nonexistent anomaly id must surface the typed ValidationException
409 // ("Feedback is submitted for an invalid anomaly", verified by
410 // probe). An IAM gap would surface AccessDeniedException and 500
411 // this route instead.
412 const result = yield* provideAnomalyFeedback({
413 AnomalyId: NONEXISTENT_ANOMALY_ID,
414 Feedback: "PLANNED_ACTIVITY",
415 }).pipe(
416 Effect.map(() => "Provided"),
417 Effect.catchTag("ValidationException", (e) =>
418 Effect.succeed(e._tag),
419 ),
420 );
421 return yield* HttpServerResponse.json({ tag: result });
422 }
423
424 if (request.method === "GET" && pathname === "/category-associations") {
425 const result = yield* listCostCategoryResourceAssociations();
426 return yield* HttpServerResponse.json({
427 count: (result.CostCategoryResourceAssociations ?? []).length,
428 });
429 }
430
431 return yield* HttpServerResponse.json(
432 { error: "Not found", method: request.method, pathname },
433 { status: 404 },
434 );
435 }).pipe(Effect.orDie),
436 };
437 }).pipe(
438 Effect.provide(
439 Layer.mergeAll(
440 Lambda.EventSource,
441 CostExplorer.GetCostAndUsageHttp,
442 CostExplorer.GetCostAndUsageComparisonsHttp,
443 CostExplorer.GetCostAndUsageWithResourcesHttp,
444 CostExplorer.GetCostComparisonDriversHttp,
445 CostExplorer.GetApproximateUsageRecordsHttp,
446 CostExplorer.GetCostForecastHttp,
447 CostExplorer.GetUsageForecastHttp,
448 CostExplorer.GetDimensionValuesHttp,
449 CostExplorer.GetTagsHttp,
450 CostExplorer.GetCostCategoriesHttp,
451 CostExplorer.GetReservationCoverageHttp,
452 CostExplorer.GetReservationPurchaseRecommendationHttp,
453 CostExplorer.GetReservationUtilizationHttp,
454 CostExplorer.GetRightsizingRecommendationHttp,
455 CostExplorer.GetSavingsPlansCoverageHttp,
456 CostExplorer.GetSavingsPlansPurchaseRecommendationHttp,
457 CostExplorer.GetSavingsPlanPurchaseRecommendationDetailsHttp,
458 CostExplorer.GetSavingsPlansUtilizationHttp,
459 CostExplorer.GetSavingsPlansUtilizationDetailsHttp,
460 CostExplorer.StartSavingsPlansPurchaseRecommendationGenerationHttp,
461 CostExplorer.ListSavingsPlansPurchaseRecommendationGenerationHttp,
462 CostExplorer.StartCommitmentPurchaseAnalysisHttp,
463 CostExplorer.GetCommitmentPurchaseAnalysisHttp,
464 CostExplorer.ListCommitmentPurchaseAnalysesHttp,
465 CostExplorer.ListCostAllocationTagsHttp,
466 CostExplorer.UpdateCostAllocationTagsStatusHttp,
467 CostExplorer.StartCostAllocationTagBackfillHttp,
468 CostExplorer.ListCostAllocationTagBackfillHistoryHttp,
469 CostExplorer.GetAnomaliesHttp,
470 CostExplorer.ProvideAnomalyFeedbackHttp,
471 CostExplorer.ListCostCategoryResourceAssociationsHttp,
472 ),
473 ),
474 ),
475);