1import * as cloudwatch from "@distilled.cloud/aws/cloudwatch";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { hasAlchemyTags } from "../../Tags.ts";
9import { AWSEnvironment, type AccountID } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import type { RegionID } from "../Region.ts";
12import {
13 createName,
14 readResourceTags,
15 retryConcurrent,
16 updateResourceTags,
17} from "./common.ts";
18
19export type AlarmName = string;
20export type AlarmArn =
21 `arn:aws:cloudwatch:${RegionID}:${AccountID}:alarm:${string}`;
22export type AlarmStateValue = cloudwatch.StateValue;
23
24export interface AlarmProps extends Omit<
25 cloudwatch.PutMetricAlarmInput,
26 "AlarmName" | "Tags"
27> {
28 /**
29 * Name of the alarm. If omitted, a unique name is generated.
30 */
31 name?: AlarmName;
32 /**
33 * Optional tags to apply to the alarm.
34 */
35 tags?: Record<string, string>;
36}
37
38export interface Alarm extends Resource<
39 "AWS.CloudWatch.Alarm",
40 AlarmProps,
41 {
42 /** Physical name of the alarm. */
43 alarmName: AlarmName;
44 /** ARN of the alarm. */
45 alarmArn: AlarmArn;
46 /** Current state of the alarm (`OK`, `ALARM`, or `INSUFFICIENT_DATA`). */
47 stateValue: AlarmStateValue | undefined;
48 /** Human-readable reason for the current state. */
49 stateReason: string | undefined;
50 /** The full MetricAlarm description as last read from CloudWatch. */
51 metricAlarm: cloudwatch.MetricAlarm;
52 /** Tags on the alarm, including the internal Alchemy ownership tags. */
53 tags: Record<string, string>;
54 },
55 never,
56 Providers
57> {}
58
59/**
60 * A CloudWatch metric alarm — watches a single metric (or metric-math
61 * expression) and transitions between `OK`, `ALARM`, and
62 * `INSUFFICIENT_DATA`, optionally firing actions on state change.
63 * ### Creating Alarms
64 * **Example:** Threshold Alarm
65 * ```typescript
66 * const alarm = yield* Alarm("HighErrors", {
67 * MetricName: "Errors",
68 * Namespace: "AWS/Lambda",
69 * Statistic: "Sum",
70 * Period: 60,
71 * EvaluationPeriods: 1,
72 * Threshold: 1,
73 * ComparisonOperator: "GreaterThanOrEqualToThreshold",
74 * });
75 * ```
76 *
77 * **Example:** Alarm on a Lambda Function's Errors
78 * ```typescript
79 * const fn = yield* MyFunction;
80 *
81 * const alarm = yield* Alarm("FnErrors", {
82 * MetricName: "Errors",
83 * Namespace: "AWS/Lambda",
84 * Dimensions: [{ Name: "FunctionName", Value: fn.functionName }],
85 * Statistic: "Sum",
86 * Period: 60,
87 * EvaluationPeriods: 1,
88 * Threshold: 1,
89 * ComparisonOperator: "GreaterThanOrEqualToThreshold",
90 * TreatMissingData: "notBreaching",
91 * });
92 * ```
93 *
94 * ### Reading Alarm State at Runtime
95 * **Example:** Read the Alarm's State from a Function
96 * ```typescript
97 * // init — bind the alarm to the function (see DescribeAlarms)
98 * const describeAlarms = yield* AWS.CloudWatch.DescribeAlarms(alarm);
99 *
100 * // runtime
101 * const result = yield* describeAlarms();
102 * const state = result.MetricAlarms?.[0]?.StateValue;
103 * ```
104 *
105 * @resource
106 */
107export const Alarm = Resource<Alarm>("AWS.CloudWatch.Alarm");
108
109export const AlarmProvider = () =>
110 Provider.effect(
111 Alarm,
112 Effect.gen(function* () {
113 const createAlarmName = (id: string, props: { name?: string } = {}) =>
114 createName(id, props.name, 255);
115
116 const alarmArn = (alarmName: string) =>
117 AWSEnvironment.current.pipe(
118 Effect.map(
119 (env) =>
120 `arn:aws:cloudwatch:${env.region}:${env.accountId}:alarm:${alarmName}` as AlarmArn,
121 ),
122 );
123
124 const readAlarm = Effect.fn(function* (alarmName: string) {
125 const described = yield* cloudwatch.describeAlarms({
126 AlarmNames: [alarmName],
127 AlarmTypes: ["MetricAlarm"],
128 });
129 const metricAlarm = described.MetricAlarms?.find(
130 (candidate) => candidate.AlarmName === alarmName,
131 );
132
133 if (!metricAlarm?.AlarmName || !metricAlarm.AlarmArn) {
134 return undefined;
135 }
136
137 const tags = yield* readResourceTags(metricAlarm.AlarmArn).pipe(
138 Effect.catchTag("ResourceNotFoundException", () =>
139 Effect.succeed({}),
140 ),
141 );
142
143 return {
144 alarmName: metricAlarm.AlarmName,
145 alarmArn: metricAlarm.AlarmArn as AlarmArn,
146 stateValue: metricAlarm.StateValue,
147 stateReason: metricAlarm.StateReason,
148 metricAlarm,
149 tags,
150 };
151 });
152
153 return {
154 stables: ["alarmName", "alarmArn"],
155 diff: Effect.fn(function* ({ id, olds = {}, news = {} }) {
156 if (!isResolved(news)) return undefined;
157 const oldName = yield* createAlarmName(id, olds);
158 const newName = yield* createAlarmName(id, news);
159
160 if (oldName !== newName) {
161 return { action: "replace" } as const;
162 }
163 }),
164 // Enumerate every MetricAlarm in the account/region by exhaustively
165 // paginating `describeAlarms` filtered to `MetricAlarm` (CompositeAlarm
166 // is owned by a separate resource). Each item is mapped to the same
167 // Attributes shape `read` produces, fetching tags per alarm.
168 list: () =>
169 Effect.gen(function* () {
170 const alarms = yield* cloudwatch.describeAlarms
171 .pages({ AlarmTypes: ["MetricAlarm"] })
172 .pipe(
173 Stream.runCollect,
174 Effect.map((chunk) =>
175 Array.from(chunk).flatMap((page) => page.MetricAlarms ?? []),
176 ),
177 );
178
179 const attrs: Alarm["Attributes"][] = yield* Effect.forEach(
180 alarms.filter(
181 (
182 metricAlarm,
183 ): metricAlarm is typeof metricAlarm & {
184 AlarmName: string;
185 AlarmArn: string;
186 } =>
187 metricAlarm.AlarmName != null && metricAlarm.AlarmArn != null,
188 ),
189 (metricAlarm) =>
190 Effect.gen(function* () {
191 const tags = yield* readResourceTags(
192 metricAlarm.AlarmArn,
193 ).pipe(
194 Effect.catchTag("ResourceNotFoundException", () =>
195 Effect.succeed({}),
196 ),
197 );
198 return {
199 alarmName: metricAlarm.AlarmName,
200 alarmArn: metricAlarm.AlarmArn as AlarmArn,
201 stateValue: metricAlarm.StateValue,
202 stateReason: metricAlarm.StateReason,
203 metricAlarm,
204 tags,
205 };
206 }),
207 { concurrency: 10 },
208 );
209 return attrs;
210 }),
211 read: Effect.fn(function* ({ id, olds, output }) {
212 const name =
213 output?.alarmName ?? (yield* createAlarmName(id, olds ?? {}));
214 const state = yield* readAlarm(name);
215 if (!state) return undefined;
216 return (yield* hasAlchemyTags(id, state.tags))
217 ? state
218 : Unowned(state);
219 }),
220 reconcile: Effect.fn(function* ({ id, news, olds, output, session }) {
221 // Observe — derive the alarm name and read whatever is currently
222 // in CloudWatch under that name. `output.alarmName` wins when
223 // present so an existing physical resource is never renamed.
224 const name = output?.alarmName ?? (yield* createAlarmName(id, news));
225 const existing = yield* readAlarm(name);
226
227 // Ensure — `putMetricAlarm` is an upsert; we always send the full
228 // desired config so the cloud converges to `news` regardless of
229 // whether the alarm pre-existed.
230 yield* retryConcurrent(
231 cloudwatch.putMetricAlarm({
232 ...news,
233 AlarmName: name,
234 }),
235 );
236
237 // Sync tags — diff observed (or prior) tags against desired and
238 // apply only the delta. On adoption `olds` is undefined, so we
239 // fall back to whatever we just observed.
240 const tags = yield* updateResourceTags({
241 id,
242 resourceArn: yield* alarmArn(name),
243 olds: olds?.tags ?? existing?.tags,
244 news: news.tags,
245 });
246
247 yield* session.note(yield* alarmArn(name));
248
249 const state = yield* readAlarm(name);
250 if (!state) {
251 return yield* Effect.fail(
252 new Error(`failed to read reconciled alarm '${name}'`),
253 );
254 }
255
256 return {
257 ...state,
258 tags,
259 };
260 }),
261 delete: Effect.fn(function* ({ output }) {
262 yield* retryConcurrent(
263 cloudwatch.deleteAlarms({
264 AlarmNames: [output.alarmName],
265 }),
266 );
267 }),
268 };
269 }),
270 );