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 } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import type { AlarmArn } from "./Alarm.ts";
12import {
13 createName,
14 readResourceTags,
15 retryConcurrent,
16 updateResourceTags,
17} from "./common.ts";
18
19export type CompositeAlarmName = string;
20
21export interface CompositeAlarmProps extends Omit<
22 cloudwatch.PutCompositeAlarmInput,
23 "AlarmName" | "Tags"
24> {
25 /**
26 * Name of the composite alarm. If omitted, a unique name is generated.
27 */
28 name?: CompositeAlarmName;
29 /**
30 * Optional tags to apply to the composite alarm.
31 */
32 tags?: Record<string, string>;
33}
34
35export interface CompositeAlarm extends Resource<
36 "AWS.CloudWatch.CompositeAlarm",
37 CompositeAlarmProps,
38 {
39 /** Physical name of the composite alarm. */
40 alarmName: CompositeAlarmName;
41 /** ARN of the composite alarm. */
42 alarmArn: AlarmArn;
43 /** Current state of the composite alarm (`OK`, `ALARM`, or `INSUFFICIENT_DATA`). */
44 stateValue: cloudwatch.StateValue | undefined;
45 /** Human-readable reason for the current state. */
46 stateReason: string | undefined;
47 /** The full CompositeAlarm description as last read from CloudWatch. */
48 compositeAlarm: cloudwatch.CompositeAlarm;
49 /** Tags on the composite alarm, including the internal Alchemy ownership tags. */
50 tags: Record<string, string>;
51 },
52 never,
53 Providers
54> {}
55
56/**
57 * A CloudWatch composite alarm — combines the states of other alarms with
58 * a boolean `AlarmRule` expression so a single alarm (and its actions)
59 * reflects overall health.
60 * ### Creating Composite Alarms
61 * **Example:** Composite Rule
62 * ```typescript
63 * const composite = yield* CompositeAlarm("HighSeverity", {
64 * AlarmRule: 'ALARM("HighErrors") OR ALARM("HighLatency")',
65 * });
66 * ```
67 *
68 * **Example:** Compose Alarm Resources with Output.interpolate
69 * ```typescript
70 * const errors = yield* Alarm("HighErrors", {
71 * MetricName: "Errors",
72 * Namespace: "AWS/Lambda",
73 * Statistic: "Sum",
74 * Period: 60,
75 * EvaluationPeriods: 1,
76 * Threshold: 1,
77 * ComparisonOperator: "GreaterThanOrEqualToThreshold",
78 * });
79 *
80 * const composite = yield* CompositeAlarm("HighSeverity", {
81 * AlarmRule: Output.interpolate`ALARM("${errors.alarmName}")`,
82 * });
83 * ```
84 *
85 * @resource
86 */
87export const CompositeAlarm = Resource<CompositeAlarm>(
88 "AWS.CloudWatch.CompositeAlarm",
89);
90
91export const CompositeAlarmProvider = () =>
92 Provider.effect(
93 CompositeAlarm,
94 Effect.gen(function* () {
95 const createAlarmName = (id: string, props: { name?: string } = {}) =>
96 createName(id, props.name, 255);
97
98 const alarmArn = (alarmName: string) =>
99 AWSEnvironment.current.pipe(
100 Effect.map(
101 (env) =>
102 `arn:aws:cloudwatch:${env.region}:${env.accountId}:alarm:${alarmName}` as AlarmArn,
103 ),
104 );
105
106 const readCompositeAlarm = Effect.fn(function* (alarmName: string) {
107 const described = yield* cloudwatch.describeAlarms({
108 AlarmNames: [alarmName],
109 AlarmTypes: ["CompositeAlarm"],
110 });
111 const compositeAlarm = described.CompositeAlarms?.find(
112 (candidate) => candidate.AlarmName === alarmName,
113 );
114
115 if (!compositeAlarm?.AlarmName || !compositeAlarm.AlarmArn) {
116 return undefined;
117 }
118
119 const tags = yield* readResourceTags(compositeAlarm.AlarmArn).pipe(
120 Effect.catchTag("ResourceNotFoundException", () =>
121 Effect.succeed({}),
122 ),
123 );
124
125 return {
126 alarmName: compositeAlarm.AlarmName,
127 alarmArn: compositeAlarm.AlarmArn as AlarmArn,
128 stateValue: compositeAlarm.StateValue,
129 stateReason: compositeAlarm.StateReason,
130 compositeAlarm,
131 tags,
132 };
133 });
134
135 return {
136 stables: ["alarmName", "alarmArn"],
137 diff: Effect.fn(function* ({ id, olds = {}, news = {} }) {
138 if (!isResolved(news)) return undefined;
139 const oldName = yield* createAlarmName(id, olds);
140 const newName = yield* createAlarmName(id, news);
141
142 if (oldName !== newName) {
143 return { action: "replace" } as const;
144 }
145 }),
146 read: Effect.fn(function* ({ id, olds, output }) {
147 const name =
148 output?.alarmName ?? (yield* createAlarmName(id, olds ?? {}));
149 const state = yield* readCompositeAlarm(name);
150 if (!state) return undefined;
151 return (yield* hasAlchemyTags(id, state.tags))
152 ? state
153 : Unowned(state);
154 }),
155 reconcile: Effect.fn(function* ({ id, news, olds, output, session }) {
156 // Observe — pin the physical name from `output` if present so we
157 // never rename an existing alarm; otherwise derive from desired
158 // props.
159 const name = output?.alarmName ?? (yield* createAlarmName(id, news));
160 const existing = yield* readCompositeAlarm(name);
161
162 // Ensure — `putCompositeAlarm` is an upsert. We always send the
163 // full desired config so the cloud converges to `news` whether
164 // the alarm pre-existed or not.
165 yield* retryConcurrent(
166 cloudwatch.putCompositeAlarm({
167 ...news,
168 AlarmName: name,
169 }),
170 );
171
172 // Sync tags — diff observed (or prior) tags against desired.
173 // `olds` is undefined on adoption, so fall back to what we just
174 // observed.
175 const tags = yield* updateResourceTags({
176 id,
177 resourceArn: yield* alarmArn(name),
178 olds: olds?.tags ?? existing?.tags,
179 news: news.tags,
180 });
181
182 yield* session.note(yield* alarmArn(name));
183
184 const state = yield* readCompositeAlarm(name);
185 if (!state) {
186 return yield* Effect.fail(
187 new Error(`failed to read reconciled composite alarm '${name}'`),
188 );
189 }
190
191 return {
192 ...state,
193 tags,
194 };
195 }),
196 delete: Effect.fn(function* ({ output }) {
197 yield* retryConcurrent(
198 cloudwatch.deleteAlarms({
199 AlarmNames: [output.alarmName],
200 }),
201 );
202 }),
203 list: () =>
204 Effect.gen(function* () {
205 // Enumerate every composite alarm in the account/region by
206 // exhaustively paginating `describeAlarms` filtered to composite
207 // alarms (items live under the `CompositeAlarms` field).
208 const alarms = yield* cloudwatch.describeAlarms
209 .pages({ AlarmTypes: ["CompositeAlarm"] })
210 .pipe(
211 Stream.runCollect,
212 Effect.map((chunk) =>
213 Array.from(chunk).flatMap((page) =>
214 (page.CompositeAlarms ?? []).filter(
215 (
216 candidate,
217 ): candidate is cloudwatch.CompositeAlarm & {
218 AlarmName: string;
219 AlarmArn: string;
220 } =>
221 candidate.AlarmName != null &&
222 candidate.AlarmArn != null,
223 ),
224 ),
225 ),
226 );
227
228 return yield* Effect.forEach(
229 alarms,
230 (compositeAlarm) =>
231 readResourceTags(compositeAlarm.AlarmArn).pipe(
232 Effect.catchTag("ResourceNotFoundException", () =>
233 Effect.succeed({}),
234 ),
235 Effect.map((tags) => ({
236 alarmName: compositeAlarm.AlarmName,
237 alarmArn: compositeAlarm.AlarmArn as AlarmArn,
238 stateValue: compositeAlarm.StateValue,
239 stateReason: compositeAlarm.StateReason,
240 compositeAlarm,
241 tags,
242 })),
243 ),
244 { concurrency: 10 },
245 );
246 }),
247 };
248 }),
249 );