1import * as CloudWatch from "@/AWS/CloudWatch";
2import * as Lambda from "@/AWS/Lambda";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import { HttpServerRequest } from "effect/http/HttpServerRequest";
6import * as HttpServerResponse from "effect/http/HttpServerResponse";
7import path from "pathe";
8
9const main = path.resolve(import.meta.dirname, "handler.ts");
10
11/**
12 * Custom metric namespace used by the fixture's alarm, anomaly detector and
13 * metric routes. Shared with Bindings.test.ts.
14 */
15export const METRIC_NAMESPACE = "Alchemy/CloudWatchBindings";
16export const METRIC_NAME = "BindingTestMetric";
17
18export class CloudWatchTestFunction extends Lambda.Function<Lambda.Function>()(
19 "CloudWatchTestFunction",
20) {}
21
22export default CloudWatchTestFunction.make(
23 {
24 main,
25 functionUrl: true,
26 },
27 Effect.gen(function* () {
28 // A metric alarm on a custom metric. Threshold is unreachably high and
29 // TreatMissingData=notBreaching so the alarm sits in OK/INSUFFICIENT_DATA
30 // and never pages anything.
31 const alarm = yield* CloudWatch.Alarm("BindingAlarm", {
32 MetricName: METRIC_NAME,
33 Namespace: METRIC_NAMESPACE,
34 Statistic: "Sum",
35 Period: 60,
36 EvaluationPeriods: 1,
37 Threshold: 1_000_000,
38 ComparisonOperator: "GreaterThanOrEqualToThreshold",
39 TreatMissingData: "notBreaching",
40 });
41
42 const dashboard = yield* CloudWatch.Dashboard("BindingDashboard", {
43 DashboardBody: {
44 widgets: [
45 {
46 type: "text",
47 x: 0,
48 y: 0,
49 width: 6,
50 height: 3,
51 properties: {
52 markdown: "# Alchemy CloudWatch bindings fixture",
53 },
54 },
55 ],
56 },
57 });
58
59 const insightRule = yield* CloudWatch.InsightRule("BindingInsightRule", {
60 RuleState: "ENABLED",
61 RuleDefinition: {
62 Schema: {
63 Name: "CloudWatchLogRule",
64 Version: 1,
65 },
66 LogGroupNames: ["alchemy-test-cloudwatch-bindings-log-group"],
67 LogFormat: "JSON",
68 Contribution: {
69 Keys: ["$.ip"],
70 Filters: [],
71 },
72 AggregateOn: "Count",
73 },
74 });
75
76 const detector = yield* CloudWatch.AnomalyDetector(
77 "BindingAnomalyDetector",
78 {
79 Namespace: METRIC_NAMESPACE,
80 MetricName: METRIC_NAME,
81 Stat: "Sum",
82 },
83 );
84 yield* detector.detectorId;
85
86 // Bindings under test (one per CloudWatch capability).
87 const describeAlarms = yield* CloudWatch.DescribeAlarms(alarm);
88 const describeAlarmContributors =
89 yield* CloudWatch.DescribeAlarmContributors(alarm);
90 const describeAlarmHistory = yield* CloudWatch.DescribeAlarmHistory();
91 const describeAlarmsForMetric = yield* CloudWatch.DescribeAlarmsForMetric();
92 const describeAnomalyDetectors =
93 yield* CloudWatch.DescribeAnomalyDetectors();
94 const describeInsightRules = yield* CloudWatch.DescribeInsightRules();
95 const disableAlarmActions = yield* CloudWatch.DisableAlarmActions(alarm);
96 const enableAlarmActions = yield* CloudWatch.EnableAlarmActions(alarm);
97 const disableInsightRules =
98 yield* CloudWatch.DisableInsightRules(insightRule);
99 const enableInsightRules =
100 yield* CloudWatch.EnableInsightRules(insightRule);
101 const getDashboard = yield* CloudWatch.GetDashboard(dashboard);
102 const getInsightRuleReport =
103 yield* CloudWatch.GetInsightRuleReport(insightRule);
104 const getMetricData = yield* CloudWatch.GetMetricData();
105 const getMetricStatistics = yield* CloudWatch.GetMetricStatistics();
106 const getMetricWidgetImage = yield* CloudWatch.GetMetricWidgetImage();
107 const listAlarmMuteRules = yield* CloudWatch.ListAlarmMuteRules();
108 const listDashboards = yield* CloudWatch.ListDashboards();
109 const listManagedInsightRules = yield* CloudWatch.ListManagedInsightRules();
110 const listMetricStreams = yield* CloudWatch.ListMetricStreams();
111 const listMetrics = yield* CloudWatch.ListMetrics();
112 const listTagsForResource = yield* CloudWatch.ListTagsForResource(alarm);
113 const putMetricData = yield* CloudWatch.PutMetricData();
114 const setAlarmState = yield* CloudWatch.SetAlarmState(alarm);
115
116 // Physical identifiers, bound into the runtime environment so routes can
117 // echo them back for containment assertions in the test.
118 const AlarmName = yield* alarm.alarmName;
119 const AlarmArn = yield* alarm.alarmArn;
120 const DashboardName = yield* dashboard.dashboardName;
121 const RuleName = yield* insightRule.ruleName;
122
123 return {
124 fetch: Effect.gen(function* () {
125 const request = yield* HttpServerRequest;
126 const url = new URL(request.originalUrl);
127 const pathname = url.pathname;
128
129 if (request.method === "GET" && pathname === "/health") {
130 return HttpServerResponse.text("ok");
131 }
132
133 // ── Metrics ────────────────────────────────────────────────────────
134
135 if (request.method === "POST" && pathname === "/put-metric-data") {
136 const result = yield* putMetricData({
137 Namespace: METRIC_NAMESPACE,
138 MetricData: [
139 {
140 MetricName: METRIC_NAME,
141 Value: 1,
142 Unit: "Count",
143 },
144 ],
145 });
146 return yield* HttpServerResponse.json({ ok: true, result });
147 }
148
149 if (request.method === "GET" && pathname === "/list-metrics") {
150 const result = yield* listMetrics({ Namespace: METRIC_NAMESPACE });
151 return yield* HttpServerResponse.json({
152 metrics: result.Metrics ?? [],
153 });
154 }
155
156 if (request.method === "GET" && pathname === "/get-metric-data") {
157 const now = yield* Effect.sync(() => Date.now());
158 const result = yield* getMetricData({
159 StartTime: new Date(now - 3_600_000),
160 EndTime: new Date(now),
161 MetricDataQueries: [
162 {
163 Id: "m1",
164 MetricStat: {
165 Metric: {
166 Namespace: METRIC_NAMESPACE,
167 MetricName: METRIC_NAME,
168 },
169 Period: 60,
170 Stat: "Sum",
171 },
172 },
173 ],
174 });
175 return yield* HttpServerResponse.json({
176 results: result.MetricDataResults ?? [],
177 });
178 }
179
180 if (request.method === "GET" && pathname === "/get-metric-statistics") {
181 const now = yield* Effect.sync(() => Date.now());
182 const result = yield* getMetricStatistics({
183 Namespace: METRIC_NAMESPACE,
184 MetricName: METRIC_NAME,
185 StartTime: new Date(now - 3_600_000),
186 EndTime: new Date(now),
187 Period: 60,
188 Statistics: ["Sum"],
189 });
190 return yield* HttpServerResponse.json({
191 label: result.Label,
192 datapoints: result.Datapoints ?? [],
193 });
194 }
195
196 if (
197 request.method === "GET" &&
198 pathname === "/get-metric-widget-image"
199 ) {
200 const result = yield* getMetricWidgetImage({
201 MetricWidget: JSON.stringify({
202 metrics: [[METRIC_NAMESPACE, METRIC_NAME]],
203 width: 300,
204 height: 200,
205 start: "-PT1H",
206 }),
207 });
208 return yield* HttpServerResponse.json({
209 bytes: result.MetricWidgetImage?.byteLength ?? 0,
210 });
211 }
212
213 // ── Alarms ─────────────────────────────────────────────────────────
214
215 if (request.method === "GET" && pathname === "/describe-alarms") {
216 const result = yield* describeAlarms();
217 return yield* HttpServerResponse.json({
218 alarmName: yield* AlarmName,
219 alarms: (result.MetricAlarms ?? []).map((a) => a.AlarmName),
220 });
221 }
222
223 if (
224 request.method === "GET" &&
225 pathname === "/describe-alarms-for-metric"
226 ) {
227 const result = yield* describeAlarmsForMetric({
228 Namespace: METRIC_NAMESPACE,
229 MetricName: METRIC_NAME,
230 Statistic: "Sum",
231 Period: 60,
232 });
233 return yield* HttpServerResponse.json({
234 alarmName: yield* AlarmName,
235 alarms: (result.MetricAlarms ?? []).map((a) => a.AlarmName),
236 });
237 }
238
239 if (
240 request.method === "GET" &&
241 pathname === "/describe-alarm-history"
242 ) {
243 const result = yield* describeAlarmHistory({
244 AlarmName: yield* AlarmName,
245 MaxRecords: 10,
246 });
247 return yield* HttpServerResponse.json({
248 items: result.AlarmHistoryItems ?? [],
249 });
250 }
251
252 if (
253 request.method === "GET" &&
254 pathname === "/describe-alarm-contributors"
255 ) {
256 // Contributor data only exists for alarms with contributor-enabled
257 // metric math; a plain metric alarm returns the typed
258 // ValidationException (observed live: empty message) or
259 // ResourceNotFoundException. Any outcome proves binding + IAM.
260 const result = yield* describeAlarmContributors().pipe(
261 Effect.map((r) => ({
262 ok: true as const,
263 contributors: r.AlarmContributors,
264 })),
265 Effect.catchTag(
266 ["ResourceNotFoundException", "ValidationException"],
267 (e) =>
268 Effect.succeed({
269 ok: false as const,
270 error: e._tag,
271 }),
272 ),
273 );
274 return yield* HttpServerResponse.json(result);
275 }
276
277 if (request.method === "POST" && pathname === "/set-alarm-state") {
278 const result = yield* setAlarmState({
279 StateValue: "OK",
280 StateReason: "alchemy-cloudwatch-bindings-test",
281 });
282 return yield* HttpServerResponse.json({ ok: true, result });
283 }
284
285 if (
286 request.method === "POST" &&
287 pathname === "/disable-alarm-actions"
288 ) {
289 const result = yield* disableAlarmActions();
290 return yield* HttpServerResponse.json({ ok: true, result });
291 }
292
293 if (request.method === "POST" && pathname === "/enable-alarm-actions") {
294 const result = yield* enableAlarmActions();
295 return yield* HttpServerResponse.json({ ok: true, result });
296 }
297
298 if (
299 request.method === "GET" &&
300 pathname === "/list-tags-for-resource"
301 ) {
302 const result = yield* listTagsForResource();
303 return yield* HttpServerResponse.json({
304 tags: result.Tags ?? [],
305 });
306 }
307
308 // ── Dashboards ─────────────────────────────────────────────────────
309
310 if (request.method === "GET" && pathname === "/get-dashboard") {
311 const result = yield* getDashboard();
312 return yield* HttpServerResponse.json({
313 dashboardName: yield* DashboardName,
314 name: result.DashboardName,
315 body: result.DashboardBody,
316 });
317 }
318
319 if (request.method === "GET" && pathname === "/list-dashboards") {
320 const result = yield* listDashboards();
321 return yield* HttpServerResponse.json({
322 dashboardName: yield* DashboardName,
323 entries: (result.DashboardEntries ?? []).map(
324 (e) => e.DashboardName,
325 ),
326 });
327 }
328
329 // ── Contributor Insights ───────────────────────────────────────────
330
331 if (
332 request.method === "GET" &&
333 pathname === "/describe-insight-rules"
334 ) {
335 const result = yield* describeInsightRules();
336 return yield* HttpServerResponse.json({
337 ruleName: yield* RuleName,
338 rules: (result.InsightRules ?? []).map((r) => r.Name),
339 });
340 }
341
342 if (
343 request.method === "GET" &&
344 pathname === "/get-insight-rule-report"
345 ) {
346 const now = yield* Effect.sync(() => Date.now());
347 const result = yield* getInsightRuleReport({
348 StartTime: new Date(now - 3_600_000),
349 EndTime: new Date(now),
350 Period: 300,
351 });
352 return yield* HttpServerResponse.json({
353 ok: true,
354 aggregationStatistic: result.AggregationStatistic,
355 contributors: result.Contributors ?? [],
356 });
357 }
358
359 if (
360 request.method === "POST" &&
361 pathname === "/disable-insight-rules"
362 ) {
363 const result = yield* disableInsightRules();
364 return yield* HttpServerResponse.json({
365 failures: result.Failures ?? [],
366 });
367 }
368
369 if (request.method === "POST" && pathname === "/enable-insight-rules") {
370 const result = yield* enableInsightRules();
371 return yield* HttpServerResponse.json({
372 failures: result.Failures ?? [],
373 });
374 }
375
376 if (
377 request.method === "GET" &&
378 pathname === "/list-managed-insight-rules"
379 ) {
380 // Managed Contributor Insights rules only exist for specific AWS
381 // resource types. We probe with the alarm ARN: a supported resource
382 // returns ManagedRules, an unsupported one returns the typed
383 // InvalidParameterValueException. Both outcomes prove binding + IAM.
384 const result = yield* listManagedInsightRules({
385 ResourceARN: yield* AlarmArn,
386 }).pipe(
387 Effect.map((r) => ({
388 ok: true as const,
389 rules: r.ManagedRules ?? [],
390 })),
391 Effect.catchTag("InvalidParameterValueException", () =>
392 Effect.succeed({
393 ok: false as const,
394 error: "InvalidParameterValueException",
395 }),
396 ),
397 );
398 return yield* HttpServerResponse.json(result);
399 }
400
401 // ── Anomaly detectors ──────────────────────────────────────────────
402
403 if (
404 request.method === "GET" &&
405 pathname === "/describe-anomaly-detectors"
406 ) {
407 const result = yield* describeAnomalyDetectors({
408 Namespace: METRIC_NAMESPACE,
409 });
410 return yield* HttpServerResponse.json({
411 detectors: result.AnomalyDetectors ?? [],
412 });
413 }
414
415 // ── Alarm mute rules / metric streams (list-only; see test notes) ──
416
417 if (request.method === "GET" && pathname === "/list-alarm-mute-rules") {
418 const result = yield* listAlarmMuteRules();
419 return yield* HttpServerResponse.json({
420 summaries: result.AlarmMuteRuleSummaries ?? [],
421 });
422 }
423
424 if (request.method === "GET" && pathname === "/list-metric-streams") {
425 const result = yield* listMetricStreams();
426 return yield* HttpServerResponse.json({
427 entries: result.Entries ?? [],
428 });
429 }
430
431 return yield* HttpServerResponse.json(
432 {
433 error: "Not found",
434 method: request.method,
435 pathname,
436 },
437 { status: 404 },
438 );
439 }).pipe(Effect.orDie),
440 };
441 }).pipe(
442 Effect.provide(
443 Layer.mergeAll(
444 CloudWatch.DescribeAlarmContributorsHttp,
445 CloudWatch.DescribeAlarmHistoryHttp,
446 CloudWatch.DescribeAlarmsHttp,
447 CloudWatch.DescribeAlarmsForMetricHttp,
448 CloudWatch.DescribeAnomalyDetectorsHttp,
449 CloudWatch.DescribeInsightRulesHttp,
450 CloudWatch.DisableAlarmActionsHttp,
451 CloudWatch.DisableInsightRulesHttp,
452 CloudWatch.EnableAlarmActionsHttp,
453 CloudWatch.EnableInsightRulesHttp,
454 CloudWatch.GetDashboardHttp,
455 CloudWatch.GetInsightRuleReportHttp,
456 CloudWatch.GetMetricDataHttp,
457 CloudWatch.GetMetricStatisticsHttp,
458 CloudWatch.GetMetricWidgetImageHttp,
459 CloudWatch.ListAlarmMuteRulesHttp,
460 CloudWatch.ListDashboardsHttp,
461 CloudWatch.ListManagedInsightRulesHttp,
462 CloudWatch.ListMetricStreamsHttp,
463 CloudWatch.ListMetricsHttp,
464 CloudWatch.ListTagsForResourceHttp,
465 CloudWatch.PutMetricDataHttp,
466 CloudWatch.SetAlarmStateHttp,
467 ),
468 ),
469 ),
470);