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
19export interface RotationProps {
20 /**
21 * The name of the rotation. Changing it replaces the rotation.
22 *
23 * @default ${app}-${id}-${stage}-${suffix}
24 */
25 name?: string;
26
27 /**
28 * ARNs of the contacts on call, in shift order.
29 */
30 contactIds: string[];
31
32 /**
33 * When the rotation goes into effect, as an ISO-8601 timestamp
34 * (e.g. `"2026-01-01T00:00:00Z"`).
35 */
36 startTime?: string;
37
38 /**
39 * The IANA time zone the rotation's activity is based on
40 * (e.g. `"America/Los_Angeles"`).
41 */
42 timeZoneId: string;
43
44 /**
45 * How often the rotation hands off between contacts: daily, weekly, or
46 * monthly hand-off settings plus the number of simultaneous on-calls and
47 * optional shift coverage windows.
48 */
49 recurrence: contacts.RecurrenceSettings;
50
51 /**
52 * Tags applied to the rotation. Alchemy ownership tags are merged in
53 * automatically.
54 */
55 tags?: Record<string, string>;
56}
57
58/** @resource */
59export interface Rotation extends Resource<
60 "AWS.SSMContacts.Rotation",
61 RotationProps,
62 {
63 /** ARN of the rotation. */
64 rotationArn: string;
65 /** Name of the rotation. */
66 name: string;
67 /** When the rotation goes into effect (ISO-8601). */
68 startTime: string | undefined;
69 },
70 never,
71 Providers
72> {}
73
74/**
75 * An Incident Manager on-call rotation — a recurring schedule that rotates
76 * engagement duty between contacts. Attach rotations to an
77 * `ONCALL_SCHEDULE` contact via its engagement plan's `RotationIds`.
78 *
79 * ### Creating Rotations
80 * **Example:** Daily hand-off rotation
81 * ```typescript
82 * const rotation = yield* SSMContacts.Rotation("Primary", {
83 * contactIds: [alice.contactArn, bob.contactArn],
84 * timeZoneId: "America/Los_Angeles",
85 * startTime: "2026-01-01T00:00:00Z",
86 * recurrence: {
87 * NumberOfOnCalls: 1,
88 * RecurrenceMultiplier: 1,
89 * DailySettings: [{ HourOfDay: 9, MinuteOfHour: 0 }],
90 * },
91 * });
92 * ```
93 */
94const RotationResource = Resource<Rotation>("AWS.SSMContacts.Rotation");
95
96export { RotationResource as Rotation };
97
98const normalize = (value: unknown): unknown => {
99 if (Array.isArray(value)) return value.map(normalize);
100 if (value !== null && typeof value === "object") {
101 return Object.fromEntries(
102 Object.entries(value as Record<string, unknown>)
103 .filter(([, v]) => v !== undefined)
104 .sort(([l], [r]) => l.localeCompare(r))
105 .map(([k, v]) => [k, normalize(v)]),
106 );
107 }
108 return value;
109};
110const same = (l: unknown, r: unknown) =>
111 JSON.stringify(normalize(l)) === JSON.stringify(normalize(r));
112
113export const RotationProvider = () =>
114 Provider.effect(
115 RotationResource,
116 Effect.gen(function* () {
117 const createName = Effect.fn(function* (
118 id: string,
119 props: { name?: string },
120 ) {
121 return (
122 props.name ?? (yield* createPhysicalName({ id, maxLength: 200 }))
123 );
124 });
125
126 const rotationArn = Effect.fn(function* (name: string) {
127 const { accountId, region } = yield* AWSEnvironment.current;
128 return `arn:aws:ssm-contacts:${region}:${accountId}:rotation/${name}`;
129 });
130
131 const getRotation = (arn: string) =>
132 contacts.getRotation({ RotationId: arn }).pipe(
133 // A rotation ARN that does not resolve surfaces as either a plain
134 // ResourceNotFoundException or the "Invalid resource Arn"
135 // ValidationException (typed as InvalidRotationArn) — both mean
136 // "missing" to the reconciler.
137 Effect.catchTag(
138 ["ResourceNotFoundException", "InvalidRotationArn"],
139 () => Effect.succeed(undefined),
140 ),
141 );
142
143 const readTags = (arn: string) =>
144 contacts.listTagsForResource({ ResourceARN: arn }).pipe(
145 // Distilled Tag has optional Key/Value; narrow to defined pairs
146 // before handing off to the shared tagRecord helper.
147 Effect.map((r) =>
148 tagRecord(
149 (r.Tags ?? []).flatMap((t) =>
150 t.Key !== undefined && t.Value !== undefined
151 ? [{ Key: t.Key, Value: t.Value }]
152 : [],
153 ),
154 ),
155 ),
156 Effect.catch(() => Effect.succeed<Record<string, string>>({})),
157 );
158
159 const buildAttrs = (
160 rotation: contacts.GetRotationResult,
161 ): Rotation["Attributes"] => ({
162 rotationArn: rotation.RotationArn,
163 name: rotation.Name,
164 startTime: rotation.StartTime.toISOString(),
165 });
166
167 return RotationResource.Provider.of({
168 stables: ["rotationArn", "name"],
169
170 list: () =>
171 contacts.listRotations.items({}).pipe(
172 Stream.runCollect,
173 Effect.map((chunk) =>
174 Array.from(chunk).map((rotation) => ({
175 rotationArn: rotation.RotationArn,
176 name: rotation.Name,
177 startTime: rotation.StartTime?.toISOString(),
178 })),
179 ),
180 ),
181
182 read: Effect.fn(function* ({ id, olds, output }) {
183 const name = output?.name ?? (yield* createName(id, olds ?? {}));
184 const arn = output?.rotationArn ?? (yield* rotationArn(name));
185 const rotation = yield* getRotation(arn);
186 if (rotation === undefined) return undefined;
187 const attrs = buildAttrs(rotation);
188 const tags = yield* readTags(rotation.RotationArn);
189 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
190 }),
191
192 // The name is create-only (updateRotation cannot rename); contacts,
193 // start time, time zone, and recurrence update in place.
194 diff: Effect.fn(function* ({ id, news, olds }) {
195 if (!isResolved(news)) return undefined;
196 const oldName = yield* createName(id, olds);
197 const newName = yield* createName(id, news);
198 if (oldName !== newName) return { action: "replace" } as const;
199 }),
200
201 reconcile: Effect.fn(function* ({ id, news, output, session }) {
202 const name = output?.name ?? (yield* createName(id, news));
203 const arn = output?.rotationArn ?? (yield* rotationArn(name));
204 const internalTags = yield* createInternalTags(id);
205 const desiredTags = { ...news.tags, ...internalTags };
206 const desiredStart =
207 news.startTime !== undefined ? new Date(news.startTime) : undefined;
208
209 // 1. OBSERVE
210 let rotation = yield* getRotation(arn);
211
212 // 2. ENSURE — create if missing; tolerate the already-exists race.
213 if (rotation === undefined) {
214 yield* session.note(`creating rotation ${name}`);
215 yield* contacts
216 .createRotation({
217 Name: name,
218 ContactIds: news.contactIds,
219 StartTime: desiredStart,
220 TimeZoneId: news.timeZoneId,
221 Recurrence: news.recurrence,
222 Tags: createTagsList(desiredTags),
223 })
224 .pipe(
225 Effect.asVoid,
226 Effect.catchTag("ConflictException", () => Effect.void),
227 );
228 rotation = (yield* getRotation(arn))!;
229 }
230
231 // 3. SYNC contacts / start time / time zone / recurrence —
232 // observed vs desired (epoch-second granularity for the start
233 // time, matching the wire format).
234 const contactsDelta = !same(rotation.ContactIds, news.contactIds);
235 const timeZoneDelta = rotation.TimeZoneId !== news.timeZoneId;
236 const startDelta =
237 desiredStart !== undefined &&
238 Math.floor(rotation.StartTime.getTime() / 1000) !==
239 Math.floor(desiredStart.getTime() / 1000);
240 const recurrenceDelta = !same(
241 {
242 MonthlySettings: rotation.Recurrence.MonthlySettings ?? [],
243 WeeklySettings: rotation.Recurrence.WeeklySettings ?? [],
244 DailySettings: rotation.Recurrence.DailySettings ?? [],
245 NumberOfOnCalls: rotation.Recurrence.NumberOfOnCalls,
246 ShiftCoverages: rotation.Recurrence.ShiftCoverages ?? {},
247 RecurrenceMultiplier: rotation.Recurrence.RecurrenceMultiplier,
248 },
249 {
250 MonthlySettings: news.recurrence.MonthlySettings ?? [],
251 WeeklySettings: news.recurrence.WeeklySettings ?? [],
252 DailySettings: news.recurrence.DailySettings ?? [],
253 NumberOfOnCalls: news.recurrence.NumberOfOnCalls,
254 ShiftCoverages: news.recurrence.ShiftCoverages ?? {},
255 RecurrenceMultiplier: news.recurrence.RecurrenceMultiplier,
256 },
257 );
258 if (contactsDelta || timeZoneDelta || startDelta || recurrenceDelta) {
259 yield* contacts.updateRotation({
260 RotationId: rotation.RotationArn,
261 ContactIds: news.contactIds,
262 StartTime: desiredStart,
263 TimeZoneId: news.timeZoneId,
264 Recurrence: news.recurrence,
265 });
266 }
267
268 // 3b. SYNC tags — diff against OBSERVED cloud tags.
269 const currentTags = yield* readTags(rotation.RotationArn);
270 const { upsert, removed } = diffTags(currentTags, desiredTags);
271 if (upsert.length > 0) {
272 yield* contacts.tagResource({
273 ResourceARN: rotation.RotationArn,
274 Tags: upsert,
275 });
276 }
277 if (removed.length > 0) {
278 yield* contacts.untagResource({
279 ResourceARN: rotation.RotationArn,
280 TagKeys: removed,
281 });
282 }
283
284 // 4. RETURN fresh attributes.
285 const final = (yield* getRotation(rotation.RotationArn))!;
286 yield* session.note(final.RotationArn);
287 return buildAttrs(final);
288 }),
289
290 delete: Effect.fn(function* ({ output }) {
291 yield* contacts
292 .deleteRotation({ RotationId: output.rotationArn })
293 .pipe(
294 Effect.asVoid,
295 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
296 );
297 }),
298 });
299 }),
300 );