1import * as devopsguru from "@distilled.cloud/aws/devops-guru";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import type { Providers } from "../Providers.ts";
8
9export interface NotificationChannelProps {
10 /**
11 * ARN of the Amazon SNS topic DevOps Guru sends notifications to. DevOps
12 * Guru only supports standard (non-FIFO) topics and adds the required
13 * topic policy on your behalf for same-account topics. Changing the topic
14 * replaces the channel.
15 */
16 topicArn: string;
17 /**
18 * Insight severities to be notified about. When omitted, notifications are
19 * sent for all severities.
20 * @default all severities
21 */
22 severities?: devopsguru.InsightSeverity[];
23 /**
24 * Notification message types to receive (e.g. `NEW_INSIGHT`,
25 * `CLOSED_INSIGHT`, `SEVERITY_UPGRADED`). When omitted, all message types
26 * are sent.
27 * @default all message types
28 */
29 messageTypes?: devopsguru.NotificationMessageType[];
30}
31
32export interface NotificationChannel extends Resource<
33 "AWS.DevOpsGuru.NotificationChannel",
34 NotificationChannelProps,
35 {
36 /** ID of the notification channel. */
37 id: string;
38 /** ARN of the SNS topic the channel notifies. */
39 topicArn: string;
40 },
41 never,
42 Providers
43> {}
44
45/**
46 * A DevOps Guru notification channel — an Amazon SNS topic that DevOps Guru
47 * uses to notify you when insights are generated, closed, or change severity.
48 *
49 * The channel configuration is immutable in the AWS API: changing the topic
50 * replaces the channel, while filter changes are converged in place by
51 * removing and re-adding the channel (the channel `id` attribute changes).
52 *
53 * ### Creating a Notification Channel
54 * **Example:** Notify an SNS topic about all insights
55 * ```typescript
56 * const topic = yield* SNS.Topic("Alerts", {});
57 *
58 * const channel = yield* DevOpsGuru.NotificationChannel("Channel", {
59 * topicArn: topic.topicArn,
60 * });
61 * ```
62 *
63 * **Example:** Filter to high-severity new insights
64 * ```typescript
65 * const channel = yield* DevOpsGuru.NotificationChannel("Channel", {
66 * topicArn: topic.topicArn,
67 * severities: ["HIGH"],
68 * messageTypes: ["NEW_INSIGHT", "SEVERITY_UPGRADED"],
69 * });
70 * ```
71 *
72 * @resource
73 */
74export const NotificationChannel = Resource<NotificationChannel>(
75 "AWS.DevOpsGuru.NotificationChannel",
76);
77
78const sameSet = (
79 left: readonly string[] | undefined,
80 right: readonly string[] | undefined,
81) => {
82 const l = [...(left ?? [])].sort();
83 const r = [...(right ?? [])].sort();
84 return l.length === r.length && l.every((value, i) => value === r[i]);
85};
86
87const matchesDesired = (
88 config: devopsguru.NotificationChannelConfig | undefined,
89 news: NotificationChannelProps,
90) =>
91 config?.Sns?.TopicArn === news.topicArn &&
92 sameSet(config?.Filters?.Severities, news.severities) &&
93 sameSet(config?.Filters?.MessageTypes, news.messageTypes);
94
95const desiredConfig = (
96 news: NotificationChannelProps,
97): devopsguru.NotificationChannelConfig => ({
98 Sns: { TopicArn: news.topicArn },
99 ...(news.severities !== undefined || news.messageTypes !== undefined
100 ? {
101 Filters: {
102 ...(news.severities !== undefined
103 ? { Severities: news.severities }
104 : {}),
105 ...(news.messageTypes !== undefined
106 ? { MessageTypes: news.messageTypes }
107 : {}),
108 },
109 }
110 : {}),
111});
112
113export const NotificationChannelProvider = () =>
114 Provider.effect(
115 NotificationChannel,
116 Effect.gen(function* () {
117 // An account has at most a handful of channels (quota is 2), so a full
118 // paginated listing is the exact observation for every operation.
119 const listChannels = devopsguru.listNotificationChannels.items({}).pipe(
120 Stream.runCollect,
121 Effect.map((chunk) => Array.from(chunk)),
122 );
123
124 const findByTopic = (
125 channels: readonly devopsguru.NotificationChannel[],
126 topicArn: string,
127 ) => channels.find((c) => c.Config?.Sns?.TopicArn === topicArn);
128
129 return {
130 stables: ["topicArn"],
131
132 list: () =>
133 listChannels.pipe(
134 Effect.map((channels) =>
135 channels.flatMap((c) =>
136 c.Id !== undefined && c.Config?.Sns?.TopicArn !== undefined
137 ? [{ id: c.Id, topicArn: c.Config.Sns.TopicArn }]
138 : [],
139 ),
140 ),
141 ),
142
143 read: Effect.fn(function* ({ olds, output }) {
144 const channels = yield* listChannels;
145 // Prefer the stored channel id; fall back to the topic lookup
146 // (state-loss recovery). A half-created state row can't round-trip
147 // an Output-valued `topicArn` — report "not found".
148 const found =
149 output?.id !== undefined
150 ? channels.find((c) => c.Id === output.id)
151 : typeof olds?.topicArn === "string"
152 ? findByTopic(channels, olds.topicArn)
153 : undefined;
154 const topicArn = found?.Config?.Sns?.TopicArn;
155 if (found?.Id === undefined || topicArn === undefined) {
156 return undefined;
157 }
158 return { id: found.Id, topicArn };
159 }),
160
161 // The channel's identity is its SNS topic — changing the topic
162 // replaces the channel. Filter changes are converged in `reconcile`.
163 diff: Effect.fn(function* ({ olds, news }) {
164 if (!isResolved(news)) return undefined;
165 if (olds.topicArn !== news.topicArn) {
166 return { action: "replace" } as const;
167 }
168 }),
169
170 reconcile: Effect.fn(function* ({ news, output, session }) {
171 // 1. OBSERVE — cloud state is authoritative; output.id is a cache.
172 const channels = yield* listChannels;
173 let observed =
174 (output?.id !== undefined
175 ? channels.find((c) => c.Id === output.id)
176 : undefined) ?? findByTopic(channels, news.topicArn);
177
178 // 2. SYNC — the config is immutable; converge drifted filters by
179 // removing and re-adding the channel.
180 if (
181 observed?.Id !== undefined &&
182 !matchesDesired(observed.Config, news)
183 ) {
184 yield* devopsguru
185 .removeNotificationChannel({ Id: observed.Id })
186 .pipe(
187 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
188 );
189 observed = undefined;
190 }
191
192 // 3. ENSURE — add the channel when missing. A ConflictException is
193 // a race with a peer reconciler; re-observe by topic.
194 let id = observed?.Id;
195 if (id === undefined) {
196 const added = yield* devopsguru
197 .addNotificationChannel({ Config: desiredConfig(news) })
198 .pipe(
199 Effect.catchTag("ConflictException", (error) =>
200 listChannels.pipe(
201 Effect.flatMap((latest) => {
202 const existing = findByTopic(latest, news.topicArn);
203 return existing?.Id !== undefined
204 ? Effect.succeed({ Id: existing.Id })
205 : Effect.fail(error);
206 }),
207 ),
208 ),
209 );
210 id = added.Id;
211 }
212
213 // 4. RETURN fresh attributes.
214 yield* session.note(id);
215 return { id, topicArn: news.topicArn };
216 }),
217
218 // removeNotificationChannel succeeds on unknown ids, but the
219 // not-found tag is still caught for belt-and-braces idempotency.
220 delete: Effect.fn(function* ({ output }) {
221 yield* devopsguru
222 .removeNotificationChannel({ Id: output.id })
223 .pipe(
224 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
225 );
226 }),
227 };
228 }),
229 );