1import * as contacts from "@distilled.cloud/aws/ssm-contacts";
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 {
10 createInternalTags,
11 createTagsList,
12 diffTags,
13 hasAlchemyTags,
14 tagRecord,
15} from "../../Tags.ts";
16import { AWSEnvironment } from "../Environment.ts";
17import type { Providers } from "../Providers.ts";
18
19/** The kind of contact: a person, an escalation plan, or an on-call schedule. */
20export type ContactType = "PERSONAL" | "ESCALATION" | "ONCALL_SCHEDULE";
21
22export interface ContactProps {
23 /**
24 * The unique, identifiable alias of the contact or escalation plan
25 * (lowercase letters, numbers, `-`, `_`, `.`). Changing it replaces the
26 * contact.
27 *
28 * @default ${app}-${id}-${stage}-${suffix} (lowercased)
29 */
30 alias?: string;
31
32 /**
33 * The full name of the contact or escalation plan.
34 */
35 displayName?: string;
36
37 /**
38 * `PERSONAL` for a single person, `ESCALATION` for an escalation plan that
39 * engages contacts in phases, or `ONCALL_SCHEDULE` for a rotation-backed
40 * schedule. Changing it replaces the contact.
41 */
42 type: ContactType;
43
44 /**
45 * The stages (engagement plan) that Incident Manager runs through when
46 * engaging this contact. Stage targets are contact channels for `PERSONAL`
47 * contacts and contacts for `ESCALATION` plans; `ONCALL_SCHEDULE` contacts
48 * reference rotations via `RotationIds`.
49 *
50 * When omitted, an existing plan is left untouched so it can be managed by
51 * the standalone `SSMContacts.Plan` resource.
52 */
53 plan?: contacts.Plan;
54
55 /**
56 * The contact's resource policy as a JSON policy document (string or
57 * object) — shares the contact and its engagements with other accounts.
58 * When omitted, any existing policy is left untouched (SSM Contacts has
59 * no delete-policy API).
60 */
61 policy?: string | Record<string, unknown>;
62
63 /**
64 * Tags applied to the contact. Alchemy ownership tags are merged in
65 * automatically.
66 */
67 tags?: Record<string, string>;
68}
69
70/** @resource */
71export interface Contact extends Resource<
72 "AWS.SSMContacts.Contact",
73 ContactProps,
74 {
75 /** ARN of the contact. */
76 contactArn: string;
77 /** Alias of the contact. */
78 alias: string;
79 /** The contact type. */
80 type: string;
81 /** Display name of the contact. */
82 displayName: string | undefined;
83 },
84 never,
85 Providers
86> {}
87
88/**
89 * An Incident Manager contact — a person Incident Manager engages during an
90 * incident, an escalation plan that engages contacts in phases, or an
91 * on-call schedule backed by rotations.
92 *
93 * Requires the account's Incident Manager replication set
94 * (`SSMIncidents.ReplicationSet`) to exist.
95 *
96 * ### Creating Contacts
97 * **Example:** Personal contact
98 * ```typescript
99 * const oncall = yield* SSMContacts.Contact("Oncall", {
100 * type: "PERSONAL",
101 * displayName: "Primary On-Call",
102 * });
103 * ```
104 *
105 * **Example:** Contact with an inline engagement plan
106 * ```typescript
107 * const channel = yield* SSMContacts.ContactChannel("Email", {
108 * contactId: oncall.contactArn,
109 * type: "EMAIL",
110 * deliveryAddress: { SimpleAddress: "oncall@example.com" },
111 * deferActivation: true,
112 * });
113 * const escalation = yield* SSMContacts.Contact("Escalation", {
114 * type: "ESCALATION",
115 * plan: {
116 * Stages: [
117 * {
118 * DurationInMinutes: 5,
119 * Targets: [
120 * { ContactTargetInfo: { ContactId: oncall.contactArn, IsEssential: true } },
121 * ],
122 * },
123 * ],
124 * },
125 * });
126 * ```
127 */
128const ContactResource = Resource<Contact>("AWS.SSMContacts.Contact");
129
130export { ContactResource as Contact };
131
132const normalize = (value: unknown): unknown => {
133 if (Array.isArray(value)) return value.map(normalize);
134 if (value !== null && typeof value === "object") {
135 return Object.fromEntries(
136 Object.entries(value as Record<string, unknown>)
137 .filter(([, v]) => v !== undefined)
138 .sort(([l], [r]) => l.localeCompare(r))
139 .map(([k, v]) => [k, normalize(v)]),
140 );
141 }
142 return value;
143};
144const same = (l: unknown, r: unknown) =>
145 JSON.stringify(normalize(l)) === JSON.stringify(normalize(r));
146
147/** Parse a policy JSON string for structural comparison; fall back to the raw string. */
148const parsePolicy = (policy: string): unknown => {
149 try {
150 return JSON.parse(policy);
151 } catch {
152 return policy;
153 }
154};
155
156/** Builds the ARN of a contact from its alias in the ambient account/region. */
157export const contactArn = Effect.fn(function* (alias: string) {
158 const { accountId, region } = yield* AWSEnvironment.current;
159 return `arn:aws:ssm-contacts:${region}:${accountId}:contact/${alias}`;
160});
161
162export const ContactProvider = () =>
163 Provider.effect(
164 ContactResource,
165 Effect.gen(function* () {
166 const createAlias = Effect.fn(function* (
167 id: string,
168 props: { alias?: string },
169 ) {
170 // Contact aliases must be lowercase.
171 return (
172 props.alias ??
173 (yield* createPhysicalName({ id, maxLength: 200, lowercase: true }))
174 );
175 });
176
177 const getContact = (arn: string) =>
178 contacts
179 .getContact({ ContactId: arn })
180 .pipe(
181 Effect.catchTag("ResourceNotFoundException", () =>
182 Effect.succeed(undefined),
183 ),
184 );
185
186 const readTags = (arn: string) =>
187 contacts.listTagsForResource({ ResourceARN: arn }).pipe(
188 // Distilled Tag has optional Key/Value; narrow to defined pairs
189 // before handing off to the shared tagRecord helper.
190 Effect.map((r) =>
191 tagRecord(
192 (r.Tags ?? []).flatMap((t) =>
193 t.Key !== undefined && t.Value !== undefined
194 ? [{ Key: t.Key, Value: t.Value }]
195 : [],
196 ),
197 ),
198 ),
199 Effect.catch(() => Effect.succeed<Record<string, string>>({})),
200 );
201
202 const buildAttrs = (contact: contacts.GetContactResult) => ({
203 contactArn: contact.ContactArn,
204 alias: contact.Alias,
205 type: contact.Type as string,
206 displayName: contact.DisplayName,
207 });
208
209 return ContactResource.Provider.of({
210 stables: ["contactArn", "alias", "type"],
211
212 list: () =>
213 contacts.listContacts.items({}).pipe(
214 Stream.runCollect,
215 Effect.map((chunk) =>
216 Array.from(chunk).map((contact) => ({
217 contactArn: contact.ContactArn,
218 alias: contact.Alias,
219 type: contact.Type as string,
220 displayName: contact.DisplayName,
221 })),
222 ),
223 ),
224
225 read: Effect.fn(function* ({ id, olds, output }) {
226 const alias = output?.alias ?? (yield* createAlias(id, olds ?? {}));
227 const arn = output?.contactArn ?? (yield* contactArn(alias));
228 const contact = yield* getContact(arn);
229 if (contact === undefined) return undefined;
230 const attrs = buildAttrs(contact);
231 const tags = yield* readTags(contact.ContactArn);
232 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
233 }),
234
235 // Alias and type are create-only; display name, plan, and tags update
236 // in place.
237 diff: Effect.fn(function* ({ id, news, olds }) {
238 if (!isResolved(news)) return undefined;
239 const oldAlias = yield* createAlias(id, olds);
240 const newAlias = yield* createAlias(id, news);
241 if (oldAlias !== newAlias) return { action: "replace" } as const;
242 if (olds.type !== news.type) return { action: "replace" } as const;
243 }),
244
245 reconcile: Effect.fn(function* ({ id, news, output, session }) {
246 const alias = output?.alias ?? (yield* createAlias(id, news));
247 const arn = output?.contactArn ?? (yield* contactArn(alias));
248 const internalTags = yield* createInternalTags(id);
249 const desiredTags = { ...news.tags, ...internalTags };
250
251 // 1. OBSERVE
252 let contact = yield* getContact(arn);
253
254 // 2. ENSURE — create if missing; tolerate the already-exists race.
255 if (contact === undefined) {
256 yield* session.note(`creating contact ${alias}`);
257 yield* contacts
258 .createContact({
259 Alias: alias,
260 DisplayName: news.displayName,
261 Type: news.type,
262 Plan: news.plan ?? { Stages: [] },
263 Tags: createTagsList(desiredTags),
264 })
265 .pipe(
266 Effect.asVoid,
267 Effect.catchTag("ConflictException", () => Effect.void),
268 );
269 contact = (yield* getContact(arn))!;
270 }
271
272 // 3. SYNC display name + plan — diff observed against desired.
273 // `plan` is only managed here when the prop is provided, so the
274 // standalone `SSMContacts.Plan` resource can own it otherwise.
275 const displayNameDelta =
276 (contact.DisplayName ?? "") !== (news.displayName ?? "");
277 const planDelta =
278 news.plan !== undefined && !same(contact.Plan, news.plan);
279 if (displayNameDelta || planDelta) {
280 yield* contacts.updateContact({
281 ContactId: contact.ContactArn,
282 DisplayName: news.displayName ?? "",
283 ...(planDelta ? { Plan: news.plan } : {}),
284 });
285 }
286
287 // 3b. SYNC resource policy — observed vs desired. Only managed
288 // when the prop is provided; there is no delete-policy API.
289 if (news.policy !== undefined) {
290 const desiredPolicy =
291 typeof news.policy === "string"
292 ? news.policy
293 : JSON.stringify(news.policy);
294 const observedPolicy = yield* contacts
295 .getContactPolicy({ ContactArn: contact.ContactArn })
296 .pipe(
297 Effect.map((r) => r.Policy),
298 Effect.catchTag("ResourceNotFoundException", () =>
299 Effect.succeed(undefined),
300 ),
301 );
302 if (
303 observedPolicy === undefined ||
304 !same(parsePolicy(observedPolicy), parsePolicy(desiredPolicy))
305 ) {
306 yield* contacts.putContactPolicy({
307 ContactArn: contact.ContactArn,
308 Policy: desiredPolicy,
309 });
310 }
311 }
312
313 // 3c. SYNC tags — diff against OBSERVED cloud tags.
314 const currentTags = yield* readTags(contact.ContactArn);
315 const { upsert, removed } = diffTags(currentTags, desiredTags);
316 if (upsert.length > 0) {
317 yield* contacts.tagResource({
318 ResourceARN: contact.ContactArn,
319 Tags: upsert,
320 });
321 }
322 if (removed.length > 0) {
323 yield* contacts.untagResource({
324 ResourceARN: contact.ContactArn,
325 TagKeys: removed,
326 });
327 }
328
329 // 4. RETURN fresh attributes.
330 const final = (yield* getContact(contact.ContactArn))!;
331 yield* session.note(final.ContactArn);
332 return buildAttrs(final);
333 }),
334
335 delete: Effect.fn(function* ({ output }) {
336 yield* contacts.deleteContact({ ContactId: output.contactArn }).pipe(
337 Effect.asVoid,
338 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
339 );
340 }),
341 });
342 }),
343 );