1import * as incidents from "@distilled.cloud/aws/ssm-incidents";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
10import type { Providers } from "../Providers.ts";
11
12export interface ResponsePlanProps {
13 /**
14 * The name of the response plan. Must be unique in the account and can't
15 * contain spaces. Changing it replaces the response plan.
16 *
17 * @default ${app}-${id}-${stage}-${suffix}
18 */
19 name?: string;
20
21 /**
22 * A human-readable name shown in the Incident Manager console.
23 */
24 displayName?: string;
25
26 /**
27 * Details used to create the incident when this response plan starts an
28 * incident: title, impact (1 = critical … 5 = no impact), optional summary,
29 * dedupe string, SNS notification targets, and tags applied to the incident
30 * record.
31 */
32 incidentTemplate: incidents.IncidentTemplate;
33
34 /**
35 * The AWS Chatbot chat channel used for collaboration during an incident.
36 */
37 chatChannel?: incidents.ChatChannel;
38
39 /**
40 * ARNs of the Incident Manager contacts and escalation plans that the
41 * response plan engages during an incident.
42 */
43 engagements?: string[];
44
45 /**
46 * The Systems Manager Automation runbooks to run at the beginning of the
47 * incident.
48 */
49 actions?: incidents.Action[];
50
51 /**
52 * PagerDuty integrations to associate with the response plan.
53 */
54 integrations?: incidents.Integration[];
55
56 /**
57 * Tags applied to the response plan. Alchemy ownership tags are merged in
58 * automatically.
59 */
60 tags?: Record<string, string>;
61}
62
63/** @resource */
64export interface ResponsePlan extends Resource<
65 "AWS.SSMIncidents.ResponsePlan",
66 ResponsePlanProps,
67 {
68 /** ARN of the response plan. */
69 arn: string;
70 /** Name of the response plan. */
71 name: string;
72 },
73 never,
74 Providers
75> {}
76
77/**
78 * An Incident Manager response plan — the template that automates the initial
79 * response to incidents by engaging contacts, starting chat-channel
80 * collaboration, and running Automation runbooks.
81 *
82 * Requires the account's Incident Manager replication set
83 * (`SSMIncidents.ReplicationSet`) to exist.
84 *
85 * ### Creating Response Plans
86 * **Example:** Minimal response plan
87 * ```typescript
88 * const replicationSet = yield* SSMIncidents.ReplicationSet("Incidents", {});
89 * const plan = yield* SSMIncidents.ResponsePlan("Critical", {
90 * incidentTemplate: { title: "Critical failure", impact: 1 },
91 * });
92 * ```
93 *
94 * **Example:** Response plan with engagements and chat channel
95 * ```typescript
96 * const plan = yield* SSMIncidents.ResponsePlan("Sev1", {
97 * displayName: "Severity 1 response",
98 * incidentTemplate: {
99 * title: "Sev1 incident",
100 * impact: 1,
101 * summary: "Automated Sev1 response",
102 * notificationTargets: [{ snsTopicArn: topic.topicArn }],
103 * },
104 * engagements: [oncall.contactArn],
105 * chatChannel: { chatbotSns: [topic.topicArn] },
106 * });
107 * ```
108 */
109const ResponsePlanResource = Resource<ResponsePlan>(
110 "AWS.SSMIncidents.ResponsePlan",
111);
112
113export { ResponsePlanResource as ResponsePlan };
114
115// Normalized deep-equality for desired-vs-observed sync comparisons —
116// tolerates key ordering and treats absent optional lists as empty.
117const normalize = (value: unknown): unknown => {
118 if (Array.isArray(value)) return value.map(normalize);
119 if (value !== null && typeof value === "object") {
120 return Object.fromEntries(
121 Object.entries(value as Record<string, unknown>)
122 .filter(([, v]) => v !== undefined)
123 .sort(([l], [r]) => l.localeCompare(r))
124 .map(([k, v]) => [k, normalize(v)]),
125 );
126 }
127 return value;
128};
129const same = (l: unknown, r: unknown) =>
130 JSON.stringify(normalize(l)) === JSON.stringify(normalize(r));
131
132export const ResponsePlanProvider = () =>
133 Provider.effect(
134 ResponsePlanResource,
135 Effect.gen(function* () {
136 const createName = Effect.fn(function* (
137 id: string,
138 props: { name?: string },
139 ) {
140 // Response plan names can't contain spaces; physical names are
141 // DNS-safe already.
142 return (
143 props.name ?? (yield* createPhysicalName({ id, maxLength: 200 }))
144 );
145 });
146
147 const findArnByName = (name: string) =>
148 incidents.listResponsePlans.items({}).pipe(
149 Stream.filter((summary) => summary.name === name),
150 Stream.take(1),
151 Stream.runCollect,
152 Effect.map((chunk) => Array.from(chunk)[0]?.arn),
153 );
154
155 const getPlan = (arn: string) =>
156 incidents
157 .getResponsePlan({ arn })
158 .pipe(
159 Effect.catchTag("ResourceNotFoundException", () =>
160 Effect.succeed(undefined),
161 ),
162 );
163
164 const observe = Effect.fn(function* (
165 name: string,
166 arn: string | undefined,
167 ) {
168 if (arn !== undefined) {
169 const byArn = yield* getPlan(arn);
170 if (byArn !== undefined) return byArn;
171 }
172 const found = yield* findArnByName(name);
173 return found === undefined ? undefined : yield* getPlan(found);
174 });
175
176 const readTags = (arn: string) =>
177 incidents.listTagsForResource({ resourceArn: arn }).pipe(
178 Effect.map(
179 (r) =>
180 Object.fromEntries(
181 Object.entries(r.tags).filter(
182 (e): e is [string, string] => e[1] !== undefined,
183 ),
184 ) as Record<string, string>,
185 ),
186 Effect.catch(() => Effect.succeed<Record<string, string>>({})),
187 );
188
189 return ResponsePlanResource.Provider.of({
190 stables: ["arn", "name"],
191
192 list: () =>
193 incidents.listResponsePlans.items({}).pipe(
194 Stream.runCollect,
195 Effect.map((chunk) =>
196 Array.from(chunk).map((summary) => ({
197 arn: summary.arn,
198 name: summary.name,
199 })),
200 ),
201 ),
202
203 read: Effect.fn(function* ({ id, olds, output }) {
204 const name = output?.name ?? (yield* createName(id, olds ?? {}));
205 const plan = yield* observe(name, output?.arn);
206 if (plan === undefined) return undefined;
207 const attrs = { arn: plan.arn, name: plan.name };
208 const tags = yield* readTags(plan.arn);
209 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
210 }),
211
212 // The name is create-only; everything else updates in place.
213 diff: Effect.fn(function* ({ id, news, olds }) {
214 if (!isResolved(news)) return undefined;
215 const oldName = yield* createName(id, olds);
216 const newName = yield* createName(id, news);
217 if (oldName !== newName) return { action: "replace" } as const;
218 }),
219
220 reconcile: Effect.fn(function* ({ id, news, output, session }) {
221 const name = output?.name ?? (yield* createName(id, news));
222 const internalTags = yield* createInternalTags(id);
223 const desiredTags = { ...news.tags, ...internalTags };
224
225 // 1. OBSERVE
226 let plan = yield* observe(name, output?.arn);
227
228 // 2. ENSURE — create if missing; tolerate the already-exists race.
229 if (plan === undefined) {
230 yield* session.note(`creating response plan ${name}`);
231 const arn = yield* incidents
232 .createResponsePlan({
233 name,
234 displayName: news.displayName,
235 incidentTemplate: news.incidentTemplate,
236 chatChannel: news.chatChannel,
237 engagements: news.engagements,
238 actions: news.actions,
239 integrations: news.integrations,
240 tags: desiredTags,
241 })
242 .pipe(
243 Effect.map((r) => r.arn),
244 Effect.catchTag("ConflictException", () =>
245 findArnByName(name).pipe(Effect.map((found) => found!)),
246 ),
247 );
248 plan = (yield* getPlan(arn))!;
249 }
250 const arn = plan.arn;
251
252 // 3. SYNC — diff each mutable aspect of the OBSERVED plan against
253 // the desired state; call updateResponsePlan only on a delta.
254 // Empty strings / empty arrays clear a previously-set value.
255 const desired = {
256 displayName: news.displayName ?? "",
257 incidentTemplate: {
258 title: news.incidentTemplate.title,
259 impact: news.incidentTemplate.impact,
260 summary: news.incidentTemplate.summary ?? "",
261 dedupeString: news.incidentTemplate.dedupeString ?? "",
262 notificationTargets:
263 news.incidentTemplate.notificationTargets ?? [],
264 incidentTags: news.incidentTemplate.incidentTags ?? {},
265 },
266 engagements: [...(news.engagements ?? [])].sort(),
267 actions: news.actions ?? [],
268 integrations: news.integrations ?? [],
269 };
270 const observed = {
271 displayName: plan.displayName ?? "",
272 incidentTemplate: {
273 title: plan.incidentTemplate.title,
274 impact: plan.incidentTemplate.impact,
275 summary: plan.incidentTemplate.summary ?? "",
276 dedupeString: plan.incidentTemplate.dedupeString ?? "",
277 notificationTargets:
278 plan.incidentTemplate.notificationTargets ?? [],
279 incidentTags: plan.incidentTemplate.incidentTags ?? {},
280 },
281 engagements: [...(plan.engagements ?? [])].sort(),
282 actions: plan.actions ?? [],
283 integrations: plan.integrations ?? [],
284 };
285 const chatChannelDelta =
286 news.chatChannel !== undefined &&
287 !same(plan.chatChannel, news.chatChannel);
288 if (!same(desired, observed) || chatChannelDelta) {
289 yield* incidents.updateResponsePlan({
290 arn,
291 displayName: desired.displayName,
292 incidentTemplateTitle: desired.incidentTemplate.title,
293 incidentTemplateImpact: desired.incidentTemplate.impact,
294 incidentTemplateSummary: desired.incidentTemplate.summary,
295 incidentTemplateDedupeString:
296 desired.incidentTemplate.dedupeString,
297 incidentTemplateNotificationTargets:
298 desired.incidentTemplate.notificationTargets,
299 incidentTemplateTags: desired.incidentTemplate.incidentTags,
300 chatChannel: news.chatChannel,
301 engagements: desired.engagements,
302 actions: desired.actions,
303 integrations: desired.integrations,
304 });
305 }
306
307 // 3b. SYNC tags — diff against OBSERVED cloud tags.
308 const currentTags = yield* readTags(arn);
309 const { upsert, removed } = diffTags(currentTags, desiredTags);
310 if (upsert.length > 0) {
311 yield* incidents.tagResource({
312 resourceArn: arn,
313 tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
314 });
315 }
316 if (removed.length > 0) {
317 yield* incidents.untagResource({
318 resourceArn: arn,
319 tagKeys: removed,
320 });
321 }
322
323 yield* session.note(arn);
324 return { arn, name };
325 }),
326
327 delete: Effect.fn(function* ({ output }) {
328 // deleteResponsePlan is idempotent for missing plans; tolerate the
329 // typed not-found in case AWS starts returning it.
330 yield* incidents
331 .deleteResponsePlan({ arn: output.arn })
332 .pipe(Effect.asVoid);
333 }),
334 });
335 }),
336 );