1import type { Credentials } from "@distilled.cloud/aws/Credentials";
2import type { Region } from "@distilled.cloud/aws/Region";
3import * as incidents from "@distilled.cloud/aws/ssm-incidents";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import * as Schedule from "effect/Schedule";
7import type { HttpClient } from "effect/http/HttpClient";
8import { Unowned } from "../../AdoptPolicy.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
12import { AWSEnvironment } from "../Environment.ts";
13import type { Providers } from "../Providers.ts";
14
15/**
16 * Raised when the replication set enters a terminal `FAILED` state (or does
17 * not become `ACTIVE` within the bounded wait window) while reconciling.
18 */
19export class ReplicationSetNotActive extends Data.TaggedError(
20 "ReplicationSetNotActive",
21)<{ message: string; status: string }> {}
22
23export interface ReplicationSetProps {
24 /**
25 * The Regions that Incident Manager replicates your data to, keyed by
26 * Region name. Each Region may specify a customer-managed KMS key for
27 * encryption at rest via `sseKmsKeyId`; omit it to use an AWS-owned key.
28 *
29 * @default the ambient deployment Region with an AWS-owned key
30 */
31 regions?: Record<string, { sseKmsKeyId?: string }>;
32
33 /**
34 * Whether deletion protection is enabled. When `true`, the replication set
35 * cannot be deleted until protection is disabled. Alchemy automatically
36 * disables protection before deleting the resource.
37 *
38 * @default false
39 */
40 deletionProtected?: boolean;
41
42 /**
43 * Tags applied to the replication set. Alchemy ownership tags are merged
44 * in automatically.
45 */
46 tags?: Record<string, string>;
47}
48
49/** @resource */
50export interface ReplicationSet extends Resource<
51 "AWS.SSMIncidents.ReplicationSet",
52 ReplicationSetProps,
53 {
54 /** ARN of the replication set. */
55 arn: string;
56 /** Current status (`ACTIVE`, `CREATING`, `UPDATING`, `DELETING`, `FAILED`). */
57 status: string;
58 /** Names of the Regions in the replication set. */
59 regionNames: string[];
60 /** Whether deletion protection is currently enabled. */
61 deletionProtected: boolean;
62 },
63 never,
64 Providers
65> {}
66
67/**
68 * The Incident Manager replication set — the account/region singleton that
69 * onboards AWS Systems Manager Incident Manager. Creating it replicates and
70 * encrypts Incident Manager data (response plans, incidents, contacts) to the
71 * configured Regions; deleting it offboards Incident Manager and removes all
72 * Incident Manager data account-wide.
73 *
74 * Only one replication set can exist per account, so this is a
75 * capture-and-restore singleton: adopting a pre-existing replication set that
76 * Alchemy did not create requires `--adopt`.
77 *
78 * ### Onboarding Incident Manager
79 * **Example:** Replication set in the current Region
80 * ```typescript
81 * const replicationSet = yield* SSMIncidents.ReplicationSet("Incidents", {});
82 * ```
83 *
84 * **Example:** Multi-Region replication with a KMS key
85 * ```typescript
86 * const replicationSet = yield* SSMIncidents.ReplicationSet("Incidents", {
87 * regions: {
88 * "us-east-1": {},
89 * "us-west-2": { sseKmsKeyId: key.keyArn },
90 * },
91 * deletionProtected: true,
92 * });
93 * ```
94 */
95const ReplicationSetResource = Resource<ReplicationSet>(
96 "AWS.SSMIncidents.ReplicationSet",
97);
98
99export { ReplicationSetResource as ReplicationSet };
100
101type IncidentsDeps = Credentials | Region | HttpClient;
102
103// Explicitly-typed pipeable helpers. Inlining `Effect.retry`/`Effect.repeat`
104// in a provider lifecycle op leaks `Retry.Return`'s conditional into
105// declaration emit and widens the provider layer to `unknown` R for every
106// consumer of `AWS.providers()`.
107
108/**
109 * Polls the replication set until it leaves its transitional state
110 * (`CREATING`/`UPDATING`), then fails with a typed error unless it landed on
111 * `ACTIVE`. Bounded: ~5s x 60 = 5 minutes (onboarding takes ~1-2 minutes).
112 */
113const waitForReplicationSetActive = (
114 arn: string,
115): Effect.Effect<
116 incidents.ReplicationSet,
117 incidents.GetReplicationSetError | ReplicationSetNotActive,
118 IncidentsDeps
119> =>
120 incidents.getReplicationSet({ arn }).pipe(
121 Effect.map((r) => r.replicationSet),
122 Effect.repeat({
123 schedule: Schedule.max([Schedule.fixed(5000), Schedule.recurs(60)]),
124 until: (rs) => rs.status !== "CREATING" && rs.status !== "UPDATING",
125 }),
126 Effect.flatMap((rs) =>
127 rs.status === "ACTIVE"
128 ? Effect.succeed(rs)
129 : Effect.fail(
130 new ReplicationSetNotActive({
131 message: `replication set ${arn} did not become ACTIVE`,
132 status: rs.status,
133 }),
134 ),
135 ),
136 );
137
138/**
139 * Polls until the replication set is fully gone (offboarding takes ~1-2
140 * minutes and a subsequent create conflicts until it completes). Bounded:
141 * ~5s x 60 = 5 minutes.
142 */
143const waitForReplicationSetGone = (
144 arn: string,
145): Effect.Effect<void, incidents.GetReplicationSetError, IncidentsDeps> =>
146 incidents.getReplicationSet({ arn }).pipe(
147 Effect.map((r) => r.replicationSet.status),
148 Effect.catchTag("ResourceNotFoundException", () =>
149 Effect.succeed("GONE" as const),
150 ),
151 Effect.repeat({
152 schedule: Schedule.max([Schedule.fixed(5000), Schedule.recurs(60)]),
153 until: (status) => status === "GONE",
154 }),
155 Effect.asVoid,
156 );
157
158export const ReplicationSetProvider = () =>
159 Provider.effect(
160 ReplicationSetResource,
161 Effect.gen(function* () {
162 // The replication set is the account singleton — its ARN is not
163 // derivable, so observation always starts from the list.
164 const observe = Effect.gen(function* () {
165 const listed = yield* incidents.listReplicationSets({});
166 const arn = listed.replicationSetArns[0];
167 if (arn === undefined) return undefined;
168 return yield* incidents.getReplicationSet({ arn }).pipe(
169 Effect.map((r) => ({ ...r.replicationSet, arn })),
170 Effect.catchTag("ResourceNotFoundException", () =>
171 Effect.succeed(undefined),
172 ),
173 );
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 const buildAttrs = (rs: incidents.ReplicationSet & { arn: string }) => ({
190 arn: rs.arn,
191 status: rs.status,
192 regionNames: Object.keys(rs.regionMap).sort(),
193 deletionProtected: rs.deletionProtected,
194 });
195
196 const desiredRegions = Effect.fn(function* (props: ReplicationSetProps) {
197 if (props.regions && Object.keys(props.regions).length > 0) {
198 return props.regions;
199 }
200 const { region } = yield* AWSEnvironment.current;
201 return { [region]: {} } as Record<string, { sseKmsKeyId?: string }>;
202 });
203
204 return ReplicationSetResource.Provider.of({
205 stables: ["arn"],
206
207 read: Effect.fn(function* ({ id, output }) {
208 const live = yield* observe;
209 if (live === undefined) return undefined;
210 const attrs = buildAttrs(live);
211 const tags = yield* readTags(live.arn);
212 if (yield* hasAlchemyTags(id, tags)) return attrs;
213 // Tag-based ownership is best-effort — if we have prior state for
214 // this exact singleton, it is ours; otherwise gate adoption.
215 return output !== undefined ? attrs : Unowned(attrs);
216 }),
217
218 // Account/region singleton — report the single replication set, if any.
219 list: () =>
220 observe.pipe(Effect.map((rs) => (rs ? [buildAttrs(rs)] : []))),
221
222 reconcile: Effect.fn(function* ({ id, news, output, session }) {
223 const internalTags = yield* createInternalTags(id);
224 const desiredTags = { ...news.tags, ...internalTags };
225 const regions = yield* desiredRegions(news);
226
227 // 1. OBSERVE — cloud state is authoritative; `output` is only an
228 // identifier cache.
229 let live = yield* observe;
230
231 // 2. ENSURE — onboard Incident Manager if there is no replication
232 // set. Tolerate the singleton-create race (ConflictException)
233 // by falling through to observation.
234 if (live === undefined) {
235 yield* session.note("creating Incident Manager replication set");
236 const created = yield* incidents
237 .createReplicationSet({
238 regions: Object.fromEntries(
239 Object.entries(regions).map(([name, cfg]) => [
240 name,
241 { sseKmsKeyId: cfg.sseKmsKeyId },
242 ]),
243 ),
244 tags: desiredTags,
245 })
246 .pipe(
247 Effect.map((r) => r.arn),
248 Effect.catchTag("ConflictException", () =>
249 incidents
250 .listReplicationSets({})
251 .pipe(Effect.map((r) => r.replicationSetArns[0]!)),
252 ),
253 );
254 yield* waitForReplicationSetActive(created);
255 live = (yield* observe)!;
256 } else if (live.status === "CREATING" || live.status === "UPDATING") {
257 // A prior reconcile may have been interrupted mid-transition.
258 yield* waitForReplicationSetActive(live.arn);
259 live = (yield* observe)!;
260 }
261 const arn = output?.arn ?? live.arn;
262
263 // 3. SYNC regions — diff observed Region map against desired and
264 // apply one add/delete action at a time (the API allows a single
265 // Region change per update). Add first so the set never shrinks
266 // to zero.
267 const observedRegions = Object.keys(live.regionMap);
268 const toAdd = Object.keys(regions).filter(
269 (r) => !observedRegions.includes(r),
270 );
271 const toRemove = observedRegions.filter((r) => !(r in regions));
272 for (const regionName of toAdd) {
273 yield* session.note(`adding region ${regionName}`);
274 yield* incidents.updateReplicationSet({
275 arn,
276 actions: [
277 {
278 addRegionAction: {
279 regionName,
280 sseKmsKeyId: regions[regionName]?.sseKmsKeyId,
281 },
282 },
283 ],
284 });
285 yield* waitForReplicationSetActive(arn);
286 }
287 for (const regionName of toRemove) {
288 yield* session.note(`removing region ${regionName}`);
289 yield* incidents.updateReplicationSet({
290 arn,
291 actions: [{ deleteRegionAction: { regionName } }],
292 });
293 yield* waitForReplicationSetActive(arn);
294 }
295
296 // 3b. SYNC deletion protection — observed vs desired.
297 const desiredProtection = news.deletionProtected ?? false;
298 if (live.deletionProtected !== desiredProtection) {
299 yield* incidents.updateDeletionProtection({
300 arn,
301 deletionProtected: desiredProtection,
302 });
303 }
304
305 // 3c. SYNC tags — diff against OBSERVED cloud tags.
306 const currentTags = yield* readTags(arn);
307 const { upsert, removed } = diffTags(currentTags, desiredTags);
308 if (upsert.length > 0) {
309 yield* incidents.tagResource({
310 resourceArn: arn,
311 tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
312 });
313 }
314 if (removed.length > 0) {
315 yield* incidents.untagResource({
316 resourceArn: arn,
317 tagKeys: removed,
318 });
319 }
320
321 // 4. RETURN fresh attributes.
322 const final = (yield* observe)!;
323 yield* session.note(arn);
324 return buildAttrs(final);
325 }),
326
327 delete: Effect.fn(function* ({ output }) {
328 // Deleting the replication set offboards Incident Manager. Deletion
329 // protection must be lifted first; both calls are idempotent.
330 yield* incidents
331 .updateDeletionProtection({
332 arn: output.arn,
333 deletionProtected: false,
334 })
335 .pipe(
336 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
337 );
338 yield* incidents.deleteReplicationSet({ arn: output.arn }).pipe(
339 Effect.asVoid,
340 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
341 );
342 // Offboarding is asynchronous (~1-2 minutes); wait until fully gone
343 // so an immediate re-create does not conflict.
344 yield* waitForReplicationSetGone(output.arn);
345 }),
346 });
347 }),
348 );