1import * as dlm from "@distilled.cloud/aws/dlm";
2import * as iam from "@distilled.cloud/aws/iam";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
11import type { Providers } from "../Providers.ts";
12
13/**
14 * The activation state of a lifecycle policy. `ERROR` is only ever observed
15 * (e.g. when the execution role was deleted out-of-band) — it cannot be set.
16 */
17export type LifecyclePolicyState = "ENABLED" | "DISABLED" | "ERROR";
18
19/**
20 * The policy type. `EBS_SNAPSHOT_MANAGEMENT` manages the lifecycle of EBS
21 * snapshots; `IMAGE_MANAGEMENT` manages the lifecycle of EBS-backed AMIs.
22 */
23export type LifecyclePolicyType =
24 | "EBS_SNAPSHOT_MANAGEMENT"
25 | "IMAGE_MANAGEMENT";
26
27/** Unit for retention intervals. */
28export type LifecyclePolicyRetentionUnit =
29 | "DAYS"
30 | "WEEKS"
31 | "MONTHS"
32 | "YEARS";
33
34export interface LifecyclePolicyCreateRule {
35 /**
36 * Where snapshots are created for policies targeting resources on an
37 * Outpost or in a Local Zone.
38 * @default "CLOUD"
39 */
40 location?: "CLOUD" | "OUTPOST_LOCAL" | "LOCAL_ZONE";
41 /**
42 * The interval between policy runs, in `intervalUnit` units (`1`, `2`,
43 * `3`, `4`, `6`, `8`, `12` or `24` hours). Mutually exclusive with
44 * `cronExpression`.
45 */
46 interval?: number;
47 /**
48 * The unit for {@link LifecyclePolicyCreateRule.interval}.
49 * @default "HOURS"
50 */
51 intervalUnit?: "HOURS";
52 /**
53 * The time(s) of day (UTC, `HH:MM`) the policy runs when using
54 * interval-based scheduling.
55 */
56 times?: string[];
57 /**
58 * A cron expression (`cron(...)`, intervals of at least 1 hour) that
59 * defines when the policy runs. Mutually exclusive with `interval`.
60 */
61 cronExpression?: string;
62}
63
64export interface LifecyclePolicyRetainRule {
65 /**
66 * Number of snapshots/AMIs to retain per volume/instance (`1` - `1000`).
67 * Mutually exclusive with `interval`/`intervalUnit`.
68 */
69 count?: number;
70 /**
71 * Age-based retention interval. Mutually exclusive with `count`.
72 */
73 interval?: number;
74 /**
75 * The unit for {@link LifecyclePolicyRetainRule.interval}.
76 */
77 intervalUnit?: LifecyclePolicyRetentionUnit;
78}
79
80export interface LifecyclePolicyFastRestoreRule {
81 /**
82 * Number of snapshots to keep fast-restore enabled for. Mutually
83 * exclusive with `interval`.
84 */
85 count?: number;
86 /**
87 * Age-based fast-restore window. Mutually exclusive with `count`.
88 */
89 interval?: number;
90 /** The unit for `interval`. */
91 intervalUnit?: LifecyclePolicyRetentionUnit;
92 /** Availability Zones in which to enable fast snapshot restore. */
93 availabilityZones?: string[];
94}
95
96export interface LifecyclePolicyCrossRegionCopyRule {
97 /**
98 * The target region (e.g. `us-east-1`) or the ARN of a target Outpost
99 * for the snapshot/AMI copies.
100 */
101 target: string;
102 /**
103 * Whether the copies are encrypted. Copies of encrypted sources are
104 * always encrypted regardless of this setting.
105 */
106 encrypted: boolean;
107 /**
108 * The ARN of the KMS key to use for the copies when encryption is
109 * enabled. Defaults to the default EBS KMS key when omitted.
110 */
111 cmkArn?: string;
112 /**
113 * Whether tags on the source are copied to the cross-region copies.
114 * @default false
115 */
116 copyTags?: boolean;
117 /** Retention rule for the cross-region copies. */
118 retainRule?: {
119 /** How long to retain the copies. */
120 interval?: number;
121 /** The unit for `interval`. */
122 intervalUnit?: LifecyclePolicyRetentionUnit;
123 };
124 /**
125 * AMI deprecation rule for the cross-region AMI copies
126 * (`IMAGE_MANAGEMENT` policies only).
127 */
128 deprecateRule?: {
129 /** How long before the AMI copies are deprecated. */
130 interval?: number;
131 /** The unit for `interval`. */
132 intervalUnit?: LifecyclePolicyRetentionUnit;
133 };
134}
135
136export interface LifecyclePolicyShareRule {
137 /** IDs of the AWS accounts to share the snapshots with. */
138 targetAccounts: string[];
139 /** Period after which the snapshots are unshared. */
140 unshareInterval?: number;
141 /** The unit for `unshareInterval`. */
142 unshareIntervalUnit?: LifecyclePolicyRetentionUnit;
143}
144
145export interface LifecyclePolicySchedule {
146 /** Name of the schedule (`0` - `120` characters). */
147 name?: string;
148 /**
149 * Whether tags are copied from the source volume/instance to the
150 * snapshot/AMI.
151 * @default false
152 */
153 copyTags?: boolean;
154 /** Tags added to every snapshot/AMI created by this schedule. */
155 tagsToAdd?: Record<string, string>;
156 /**
157 * Variable tags added to snapshots (`EBS_SNAPSHOT_MANAGEMENT` policies
158 * targeting instances only). Values may use `$(instance-id)` and
159 * `$(timestamp)`.
160 */
161 variableTags?: Record<string, string>;
162 /** When the schedule creates snapshots/AMIs. */
163 createRule?: LifecyclePolicyCreateRule;
164 /** How long the snapshots/AMIs are retained. */
165 retainRule?: LifecyclePolicyRetainRule;
166 /**
167 * Fast snapshot restore rule (`EBS_SNAPSHOT_MANAGEMENT` policies only).
168 */
169 fastRestoreRule?: LifecyclePolicyFastRestoreRule;
170 /** Cross-region copy rules (up to 3). */
171 crossRegionCopyRules?: LifecyclePolicyCrossRegionCopyRule[];
172 /**
173 * Snapshot sharing rules (`EBS_SNAPSHOT_MANAGEMENT` policies only).
174 */
175 shareRules?: LifecyclePolicyShareRule[];
176 /**
177 * AMI deprecation rule (`IMAGE_MANAGEMENT` policies only). Mutually
178 * exclusive constraints mirror the retain rule (`count` vs `interval`).
179 */
180 deprecateRule?: {
181 /** Number of newest AMIs to keep un-deprecated. */
182 count?: number;
183 /** Age after which AMIs are deprecated. */
184 interval?: number;
185 /** The unit for `interval`. */
186 intervalUnit?: LifecyclePolicyRetentionUnit;
187 };
188 /**
189 * Snapshot archiving rule (`EBS_SNAPSHOT_MANAGEMENT` policies with
190 * count-based retention only).
191 */
192 archiveRule?: {
193 /** Retention for snapshots in the archive tier. */
194 retainRule: {
195 /** Archive-tier retention settings. */
196 retentionArchiveTier: {
197 /** Number of snapshots to retain in the archive tier. */
198 count?: number;
199 /** Age-based archive-tier retention. */
200 interval?: number;
201 /** The unit for `interval`. */
202 intervalUnit?: LifecyclePolicyRetentionUnit;
203 };
204 };
205 };
206}
207
208export interface LifecyclePolicyDetails {
209 /**
210 * The policy type. Changing the type triggers a replacement.
211 * @default "EBS_SNAPSHOT_MANAGEMENT"
212 */
213 policyType?: LifecyclePolicyType;
214 /**
215 * The resource type targeted by the policy. `VOLUME` creates snapshots
216 * of individual volumes; `INSTANCE` creates multi-volume snapshot sets
217 * (or AMIs for `IMAGE_MANAGEMENT`).
218 */
219 resourceTypes?: ("VOLUME" | "INSTANCE")[];
220 /**
221 * The location of the targeted resources.
222 * @default ["CLOUD"]
223 */
224 resourceLocations?: ("CLOUD" | "OUTPOST" | "LOCAL_ZONE")[];
225 /**
226 * The resource tags that identify the volumes/instances the policy
227 * applies to.
228 */
229 targetTags?: Record<string, string>;
230 /** The schedules (1 mandatory + up to 3 optional). */
231 schedules?: LifecyclePolicySchedule[];
232 /** Options specific to the policy/resource type. */
233 parameters?: {
234 /**
235 * Exclude the boot volume from multi-volume snapshot sets
236 * (`EBS_SNAPSHOT_MANAGEMENT` targeting instances only).
237 * @default false
238 */
239 excludeBootVolume?: boolean;
240 /**
241 * Skip rebooting the instance when creating AMIs
242 * (`IMAGE_MANAGEMENT` only).
243 * @default true
244 */
245 noReboot?: boolean;
246 /**
247 * Exclude data volumes with these tags from multi-volume snapshot
248 * sets (`EBS_SNAPSHOT_MANAGEMENT` targeting instances only).
249 */
250 excludeDataVolumeTags?: Record<string, string>;
251 };
252}
253
254export interface LifecyclePolicyProps {
255 /**
256 * Description of the policy (`0` - `500` characters; letters, digits,
257 * spaces, `_` and `-`). If omitted, a deterministic description is
258 * generated from the app, stage, and logical ID.
259 */
260 description?: string;
261 /**
262 * The activation state of the policy.
263 * @default "ENABLED"
264 */
265 state?: "ENABLED" | "DISABLED";
266 /**
267 * The ARN of an existing IAM role Amazon Data Lifecycle Manager assumes
268 * to run the policy. When omitted, an execution role is created
269 * automatically with `dlm.amazonaws.com` trust and the AWS managed
270 * policy matching the policy type (`AWSDataLifecycleManagerServiceRole`
271 * for snapshot policies, `AWSDataLifecycleManagerServiceRoleForAMIManagement`
272 * for AMI policies).
273 */
274 executionRoleArn?: string;
275 /**
276 * The configuration of the policy: what it targets and when/how it
277 * creates and retains snapshots or AMIs.
278 */
279 policyDetails: LifecyclePolicyDetails;
280 /**
281 * Tags to apply to the lifecycle policy. Merged with internal Alchemy
282 * tags.
283 */
284 tags?: Record<string, string>;
285}
286
287export interface LifecyclePolicy extends Resource<
288 "AWS.DLM.LifecyclePolicy",
289 LifecyclePolicyProps,
290 {
291 /** The auto-assigned ID of the policy (e.g. `policy-0123456789abcdef0`). */
292 policyId: string;
293 /** ARN of the policy. */
294 policyArn: string;
295 /** The activation state of the policy. */
296 state: LifecyclePolicyState;
297 /** The ARN of the IAM role the policy runs as. */
298 executionRoleArn: string;
299 /**
300 * Name of the auto-created execution role. `undefined` when an
301 * explicit {@link LifecyclePolicyProps.executionRoleArn} is used.
302 */
303 roleName: string | undefined;
304 },
305 never,
306 Providers
307> {}
308
309/**
310 * An Amazon Data Lifecycle Manager (DLM) lifecycle policy that automates the
311 * creation, retention, and deletion of EBS snapshots or EBS-backed AMIs.
312 *
313 * `LifecyclePolicy` owns a custom `EBS_SNAPSHOT_MANAGEMENT` or
314 * `IMAGE_MANAGEMENT` policy. Volumes/instances are targeted by tags, and an
315 * execution role is created automatically unless an explicit
316 * `executionRoleArn` is given.
317 * ### Creating Policies
318 * **Example:** Daily EBS snapshots retained for a week
319 * ```typescript
320 * import * as DLM from "alchemy/AWS/DLM";
321 *
322 * const policy = yield* DLM.LifecyclePolicy("DailySnapshots", {
323 * policyDetails: {
324 * resourceTypes: ["VOLUME"],
325 * targetTags: { Backup: "daily" },
326 * schedules: [
327 * {
328 * name: "Daily",
329 * createRule: { interval: 24, intervalUnit: "HOURS", times: ["03:00"] },
330 * retainRule: { count: 7 },
331 * },
332 * ],
333 * },
334 * });
335 * ```
336 *
337 * **Example:** Cron-scheduled snapshots
338 * ```typescript
339 * const policy = yield* DLM.LifecyclePolicy("WeeklySnapshots", {
340 * description: "Weekly volume snapshots",
341 * policyDetails: {
342 * resourceTypes: ["VOLUME"],
343 * targetTags: { Backup: "weekly" },
344 * schedules: [
345 * {
346 * name: "Weekly",
347 * copyTags: true,
348 * createRule: { cronExpression: "cron(0 4 ? * SUN *)" },
349 * retainRule: { interval: 1, intervalUnit: "MONTHS" },
350 * },
351 * ],
352 * },
353 * });
354 * ```
355 *
356 * ### AMI Policies
357 * **Example:** EBS-backed AMIs of tagged instances
358 * ```typescript
359 * const amis = yield* DLM.LifecyclePolicy("NightlyAmis", {
360 * policyDetails: {
361 * policyType: "IMAGE_MANAGEMENT",
362 * resourceTypes: ["INSTANCE"],
363 * targetTags: { Backup: "ami" },
364 * parameters: { noReboot: true },
365 * schedules: [
366 * {
367 * name: "Nightly",
368 * createRule: { interval: 24, intervalUnit: "HOURS", times: ["05:00"] },
369 * retainRule: { count: 3 },
370 * },
371 * ],
372 * },
373 * });
374 * ```
375 *
376 * ### Execution Role
377 * **Example:** Bring your own execution role
378 * ```typescript
379 * const role = yield* IAM.Role("DlmRole", {
380 * assumeRolePolicyDocument: {
381 * Version: "2012-10-17",
382 * Statement: [
383 * {
384 * Effect: "Allow",
385 * Principal: { Service: "dlm.amazonaws.com" },
386 * Action: "sts:AssumeRole",
387 * },
388 * ],
389 * },
390 * managedPolicyArns: [
391 * "arn:aws:iam::aws:policy/service-role/AWSDataLifecycleManagerServiceRole",
392 * ],
393 * });
394 *
395 * const policy = yield* DLM.LifecyclePolicy("Snapshots", {
396 * executionRoleArn: role.roleArn,
397 * state: "DISABLED",
398 * policyDetails: {
399 * resourceTypes: ["VOLUME"],
400 * targetTags: { Backup: "true" },
401 * schedules: [
402 * {
403 * name: "Daily",
404 * createRule: { interval: 24, intervalUnit: "HOURS" },
405 * retainRule: { count: 2 },
406 * },
407 * ],
408 * },
409 * });
410 * ```
411 *
412 * @resource
413 */
414export const LifecyclePolicy = Resource<LifecyclePolicy>(
415 "AWS.DLM.LifecyclePolicy",
416);
417
418/** AWS managed policy granting DLM the permissions for snapshot policies. */
419const SNAPSHOT_MANAGED_POLICY_ARN =
420 "arn:aws:iam::aws:policy/service-role/AWSDataLifecycleManagerServiceRole";
421
422/** AWS managed policy granting DLM the permissions for AMI policies. */
423const AMI_MANAGED_POLICY_ARN =
424 "arn:aws:iam::aws:policy/service-role/AWSDataLifecycleManagerServiceRoleForAMIManagement";
425
426/** Convert a tag record to the DLM wire tag list, sorted for stable diffs. */
427const toWireTagList = (
428 tags: Record<string, string> | undefined,
429): dlm.Tag[] | undefined =>
430 tags === undefined
431 ? undefined
432 : Object.entries(tags)
433 .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
434 .map(([Key, Value]) => ({ Key, Value }));
435
436/** Convert a wire tag map (values possibly undefined) to a plain record. */
437const fromWireTagMap = (
438 tags: { [key: string]: string | undefined } | undefined,
439): Record<string, string> =>
440 Object.fromEntries(
441 Object.entries(tags ?? {}).filter(
442 (entry): entry is [string, string] => entry[1] !== undefined,
443 ),
444 );
445
446/** Map the camelCase schedule props to the DLM wire shape. */
447const toWireSchedule = (schedule: LifecyclePolicySchedule): dlm.Schedule => ({
448 Name: schedule.name,
449 CopyTags: schedule.copyTags,
450 TagsToAdd: toWireTagList(schedule.tagsToAdd),
451 VariableTags: toWireTagList(schedule.variableTags),
452 CreateRule:
453 schedule.createRule === undefined
454 ? undefined
455 : {
456 Location: schedule.createRule.location,
457 Interval: schedule.createRule.interval,
458 IntervalUnit: schedule.createRule.intervalUnit,
459 Times: schedule.createRule.times,
460 CronExpression: schedule.createRule.cronExpression,
461 },
462 RetainRule:
463 schedule.retainRule === undefined
464 ? undefined
465 : {
466 Count: schedule.retainRule.count,
467 Interval: schedule.retainRule.interval,
468 IntervalUnit: schedule.retainRule.intervalUnit,
469 },
470 FastRestoreRule:
471 schedule.fastRestoreRule === undefined
472 ? undefined
473 : {
474 Count: schedule.fastRestoreRule.count,
475 Interval: schedule.fastRestoreRule.interval,
476 IntervalUnit: schedule.fastRestoreRule.intervalUnit,
477 AvailabilityZones: schedule.fastRestoreRule.availabilityZones,
478 },
479 CrossRegionCopyRules: schedule.crossRegionCopyRules?.map((rule) => ({
480 Target: rule.target,
481 Encrypted: rule.encrypted,
482 CmkArn: rule.cmkArn,
483 CopyTags: rule.copyTags,
484 RetainRule:
485 rule.retainRule === undefined
486 ? undefined
487 : {
488 Interval: rule.retainRule.interval,
489 IntervalUnit: rule.retainRule.intervalUnit,
490 },
491 DeprecateRule:
492 rule.deprecateRule === undefined
493 ? undefined
494 : {
495 Interval: rule.deprecateRule.interval,
496 IntervalUnit: rule.deprecateRule.intervalUnit,
497 },
498 })),
499 ShareRules: schedule.shareRules?.map((rule) => ({
500 TargetAccounts: rule.targetAccounts,
501 UnshareInterval: rule.unshareInterval,
502 UnshareIntervalUnit: rule.unshareIntervalUnit,
503 })),
504 DeprecateRule:
505 schedule.deprecateRule === undefined
506 ? undefined
507 : {
508 Count: schedule.deprecateRule.count,
509 Interval: schedule.deprecateRule.interval,
510 IntervalUnit: schedule.deprecateRule.intervalUnit,
511 },
512 ArchiveRule:
513 schedule.archiveRule === undefined
514 ? undefined
515 : {
516 RetainRule: {
517 RetentionArchiveTier: {
518 Count: schedule.archiveRule.retainRule.retentionArchiveTier.count,
519 Interval:
520 schedule.archiveRule.retainRule.retentionArchiveTier.interval,
521 IntervalUnit:
522 schedule.archiveRule.retainRule.retentionArchiveTier
523 .intervalUnit,
524 },
525 },
526 },
527});
528
529/** Map the camelCase policy details props to the DLM wire shape. */
530const toWirePolicyDetails = (
531 details: LifecyclePolicyDetails,
532): dlm.PolicyDetails => ({
533 PolicyType: details.policyType ?? "EBS_SNAPSHOT_MANAGEMENT",
534 ResourceTypes: details.resourceTypes,
535 ResourceLocations: details.resourceLocations,
536 TargetTags: toWireTagList(details.targetTags),
537 Schedules: details.schedules?.map(toWireSchedule),
538 Parameters:
539 details.parameters === undefined
540 ? undefined
541 : {
542 ExcludeBootVolume: details.parameters.excludeBootVolume,
543 NoReboot: details.parameters.noReboot,
544 ExcludeDataVolumeTags: toWireTagList(
545 details.parameters.excludeDataVolumeTags,
546 ),
547 },
548});
549
550/**
551 * Canonicalize a wire value for drift comparison: drop `undefined` members,
552 * sort object keys, and sort `{Key, Value}` tag lists by key so record-order
553 * differences never register as drift.
554 */
555const canonicalize = (value: unknown): unknown => {
556 if (Array.isArray(value)) {
557 const items = value.map(canonicalize);
558 const isTagList = items.every(
559 (item) =>
560 item !== null &&
561 typeof item === "object" &&
562 !Array.isArray(item) &&
563 Object.keys(item).every((k) => k === "Key" || k === "Value"),
564 );
565 return isTagList
566 ? [...items].sort((a, b) => {
567 const ak = (a as { Key?: string }).Key ?? "";
568 const bk = (b as { Key?: string }).Key ?? "";
569 return ak < bk ? -1 : ak > bk ? 1 : 0;
570 })
571 : items;
572 }
573 if (value !== null && typeof value === "object") {
574 return Object.fromEntries(
575 Object.entries(value as Record<string, unknown>)
576 .filter(([, v]) => v !== undefined)
577 .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
578 .map(([k, v]) => [k, canonicalize(v)]),
579 );
580 }
581 return value;
582};
583
584/**
585 * Project the observed value down to the keys present in the desired value,
586 * recursively. DLM normalizes policies on read (adding defaults like
587 * `PolicyLanguage`, `ResourceLocations`, or `CopyTags: false`), so comparing
588 * the full observed document against our sparse desired document would flag
589 * drift on every reconcile. Keys we do not manage are ignored.
590 */
591const projectToDesired = (desired: unknown, observed: unknown): unknown => {
592 if (Array.isArray(desired)) {
593 if (!Array.isArray(observed)) return observed;
594 if (desired.length !== observed.length) return observed;
595 return desired.map((item, i) => projectToDesired(item, observed[i]));
596 }
597 if (
598 desired !== null &&
599 typeof desired === "object" &&
600 observed !== null &&
601 typeof observed === "object" &&
602 !Array.isArray(observed)
603 ) {
604 return Object.fromEntries(
605 Object.entries(desired as Record<string, unknown>)
606 .filter(([, v]) => v !== undefined)
607 .map(([k, v]) => [
608 k,
609 projectToDesired(v, (observed as Record<string, unknown>)[k]),
610 ]),
611 );
612 }
613 return observed;
614};
615
616/** Whether the observed wire document drifted from the desired one. */
617const policyDetailsDrifted = (
618 desired: dlm.PolicyDetails,
619 observed: dlm.PolicyDetails | undefined,
620): boolean =>
621 JSON.stringify(canonicalize(desired)) !==
622 JSON.stringify(canonicalize(projectToDesired(desired, observed ?? {})));
623
624/**
625 * DLM validates the execution role at create/update time; a freshly created
626 * role can be transiently rejected with `InvalidRequestException` until IAM
627 * propagation completes. Bounded retry, explicitly typed so declaration emit
628 * never widens the provider layer (see PATTERNS §7).
629 */
630const retryWhileRolePropagates = <A, E extends { _tag: string }, R>(
631 self: Effect.Effect<A, E, R>,
632): Effect.Effect<A, E, R> =>
633 Effect.retry(self, {
634 while: (e) => e._tag === "InvalidRequestException",
635 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(5)]),
636 });
637
638export const LifecyclePolicyProvider = () =>
639 Provider.effect(
640 LifecyclePolicy,
641 Effect.gen(function* () {
642 const createDescription = Effect.fn(function* (
643 id: string,
644 props: Pick<LifecyclePolicyProps, "description">,
645 ) {
646 return (
647 props.description ??
648 (yield* createPhysicalName({ id, maxLength: 500 }))
649 );
650 });
651
652 const createRoleName = (id: string) =>
653 createPhysicalName({ id, maxLength: 64 });
654
655 const managedPolicyArnFor = (details: LifecyclePolicyDetails) =>
656 (details.policyType ?? "EBS_SNAPSHOT_MANAGEMENT") === "IMAGE_MANAGEMENT"
657 ? AMI_MANAGED_POLICY_ARN
658 : SNAPSHOT_MANAGED_POLICY_ARN;
659
660 const getOrUndefined = Effect.fn(function* (policyId: string) {
661 return yield* dlm.getLifecyclePolicy({ PolicyId: policyId }).pipe(
662 Effect.map((r) => r.Policy),
663 Effect.catchTag("ResourceNotFoundException", () =>
664 Effect.succeed(undefined),
665 ),
666 );
667 });
668
669 const buildAttrs = (
670 policy: dlm.LifecyclePolicy,
671 roleName: string | undefined,
672 ) => ({
673 policyId: policy.PolicyId!,
674 policyArn: policy.PolicyArn!,
675 state: (policy.State ?? "ENABLED") as LifecyclePolicyState,
676 executionRoleArn: policy.ExecutionRoleArn!,
677 roleName,
678 });
679
680 /**
681 * Ensure the auto-created execution role exists with `dlm.amazonaws.com`
682 * trust and the AWS managed policy matching the policy type. Idempotent:
683 * tolerates the role already existing; attaching an already-attached
684 * managed policy is a no-op.
685 */
686 const ensureExecutionRole = Effect.fn(function* ({
687 id,
688 roleName,
689 managedPolicyArn,
690 }: {
691 id: string;
692 roleName: string;
693 managedPolicyArn: string;
694 }) {
695 const tags = yield* createInternalTags(id);
696 const role = yield* iam
697 .createRole({
698 RoleName: roleName,
699 AssumeRolePolicyDocument: JSON.stringify({
700 Version: "2012-10-17",
701 Statement: [
702 {
703 Effect: "Allow",
704 Principal: { Service: "dlm.amazonaws.com" },
705 Action: "sts:AssumeRole",
706 },
707 ],
708 }),
709 Tags: Object.entries(tags).map(([Key, Value]) => ({ Key, Value })),
710 })
711 .pipe(
712 Effect.catchTag("EntityAlreadyExistsException", () =>
713 iam.getRole({ RoleName: roleName }),
714 ),
715 );
716 yield* iam.attachRolePolicy({
717 RoleName: roleName,
718 PolicyArn: managedPolicyArn,
719 });
720 return role.Role.Arn;
721 });
722
723 return LifecyclePolicy.Provider.of({
724 stables: ["policyId", "policyArn"],
725
726 // Enumerate every lifecycle policy in the ambient account/region.
727 // getLifecyclePolicies is unpaginated; hydrate each summary because
728 // the full attributes (ARN, role) only appear on the detail call. A
729 // policy can vanish between enumeration and hydration — drop it.
730 list: () =>
731 Effect.gen(function* () {
732 const { Policies } = yield* dlm.getLifecyclePolicies({});
733 const items = yield* Effect.forEach(
734 Policies ?? [],
735 (summary) =>
736 summary.PolicyId === undefined
737 ? Effect.succeed(undefined)
738 : getOrUndefined(summary.PolicyId).pipe(
739 Effect.map((policy) =>
740 policy?.PolicyId !== undefined &&
741 policy.PolicyArn !== undefined &&
742 policy.ExecutionRoleArn !== undefined
743 ? buildAttrs(policy, undefined)
744 : undefined,
745 ),
746 ),
747 { concurrency: 5 },
748 );
749 return items.filter(
750 (item): item is LifecyclePolicy["Attributes"] =>
751 item !== undefined,
752 );
753 }),
754
755 read: Effect.fn(function* ({ id, output }) {
756 // Policy ids are server-assigned; without an output cache there is
757 // no deterministic identity to look up. (A tag-based search would
758 // mis-resolve the OLD instance during a replacement, since both
759 // instances share the same logical-id tags.)
760 if (output?.policyId === undefined) return undefined;
761 const policy = yield* getOrUndefined(output.policyId);
762 if (
763 policy?.PolicyId === undefined ||
764 policy.PolicyArn === undefined ||
765 policy.ExecutionRoleArn === undefined
766 ) {
767 return undefined;
768 }
769 const attrs = buildAttrs(policy, output?.roleName);
770 return (yield* hasAlchemyTags(id, fromWireTagMap(policy.Tags)))
771 ? attrs
772 : Unowned(attrs);
773 }),
774
775 diff: Effect.fn(function* ({ news, olds }) {
776 if (!isResolved(news)) return undefined;
777 const oldType =
778 olds?.policyDetails?.policyType ?? "EBS_SNAPSHOT_MANAGEMENT";
779 const newType =
780 news.policyDetails?.policyType ?? "EBS_SNAPSHOT_MANAGEMENT";
781 if (oldType !== newType) {
782 // The policy type of an existing policy cannot be changed.
783 return { action: "replace" } as const;
784 }
785 // description/state/role/details/tags converge via update
786 }),
787
788 reconcile: Effect.fn(function* ({ id, news, output, session }) {
789 const description = yield* createDescription(id, news);
790 const desiredState = news.state ?? "ENABLED";
791 const desiredDetails = toWirePolicyDetails(news.policyDetails ?? {});
792 const internalTags = yield* createInternalTags(id);
793 const desiredTags = { ...news.tags, ...internalTags };
794
795 // Ensure the execution role first — the policy cannot exist
796 // without one. Managed unless an explicit executionRoleArn is
797 // provided.
798 let executionRoleArn = news.executionRoleArn;
799 let roleName: string | undefined;
800 if (executionRoleArn === undefined) {
801 roleName = yield* createRoleName(id);
802 executionRoleArn = yield* ensureExecutionRole({
803 id,
804 roleName,
805 managedPolicyArn: managedPolicyArnFor(news.policyDetails ?? {}),
806 });
807 }
808
809 // 1. OBSERVE — cloud state is authoritative; `output` is only an
810 // id cache. Policy ids are server-assigned, so with no cached
811 // id the policy is (as far as we can know) missing.
812 let policyId = output?.policyId;
813 let observed =
814 policyId === undefined
815 ? undefined
816 : yield* getOrUndefined(policyId);
817
818 if (observed === undefined) {
819 // 2. ENSURE — create. IAM propagation of a fresh role is
820 // eventually consistent; DLM rejects it with a transient
821 // InvalidRequestException, so retry briefly.
822 const created = yield* retryWhileRolePropagates(
823 dlm.createLifecyclePolicy({
824 ExecutionRoleArn: executionRoleArn,
825 Description: description,
826 State: desiredState,
827 PolicyDetails: desiredDetails,
828 Tags: desiredTags,
829 }),
830 );
831 policyId = created.PolicyId!;
832 observed = yield* getOrUndefined(policyId);
833 } else {
834 // 3. SYNC — diff OBSERVED cloud state against desired and issue
835 // a single update only when something actually drifted. DLM
836 // normalizes documents on read (defaults like PolicyLanguage
837 // or ResourceLocations appear), so the comparison projects
838 // the observed document down to the keys we manage.
839 const drifted =
840 observed.Description !== description ||
841 (observed.State ?? "ENABLED") !== desiredState ||
842 observed.ExecutionRoleArn !== executionRoleArn ||
843 policyDetailsDrifted(desiredDetails, observed.PolicyDetails);
844 if (drifted) {
845 yield* retryWhileRolePropagates(
846 dlm.updateLifecyclePolicy({
847 PolicyId: policyId!,
848 ExecutionRoleArn: executionRoleArn,
849 Description: description,
850 State: desiredState,
851 PolicyDetails: desiredDetails,
852 }),
853 );
854 observed = yield* getOrUndefined(policyId!);
855 }
856 }
857
858 if (
859 observed?.PolicyId === undefined ||
860 observed.PolicyArn === undefined ||
861 observed.ExecutionRoleArn === undefined
862 ) {
863 // The policy vanished between create/update and the final read —
864 // surface as a typed not-found so the engine can retry the plan.
865 return yield* Effect.fail(
866 new dlm.ResourceNotFoundException({
867 message: `DLM lifecycle policy ${policyId} disappeared during reconcile`,
868 }),
869 );
870 }
871
872 // 3b. SYNC TAGS — against OBSERVED cloud tags so adoption
873 // converges (create-time tags only apply on first create).
874 const observedTags = fromWireTagMap(observed.Tags);
875 const { upsert, removed } = diffTags(observedTags, desiredTags);
876 if (upsert.length > 0) {
877 yield* dlm.tagResource({
878 ResourceArn: observed.PolicyArn,
879 Tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
880 });
881 }
882 if (removed.length > 0) {
883 yield* dlm.untagResource({
884 ResourceArn: observed.PolicyArn,
885 TagKeys: removed,
886 });
887 }
888
889 yield* session.note(observed.PolicyId);
890 return buildAttrs(observed, roleName);
891 }),
892
893 delete: Effect.fn(function* ({ output }) {
894 yield* dlm
895 .deleteLifecyclePolicy({ PolicyId: output.policyId })
896 .pipe(
897 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
898 );
899
900 // Tear down the managed execution role (absent when the user
901 // supplied an explicit executionRoleArn). Every step tolerates
902 // the role being partially or fully gone already.
903 if (output.roleName !== undefined) {
904 const roleName = output.roleName;
905 yield* iam.listAttachedRolePolicies({ RoleName: roleName }).pipe(
906 Effect.flatMap((r) =>
907 Effect.forEach(r.AttachedPolicies ?? [], (policy) =>
908 policy.PolicyArn === undefined
909 ? Effect.void
910 : iam
911 .detachRolePolicy({
912 RoleName: roleName,
913 PolicyArn: policy.PolicyArn,
914 })
915 .pipe(
916 Effect.catchTag(
917 "NoSuchEntityException",
918 () => Effect.void,
919 ),
920 ),
921 ),
922 ),
923 Effect.catchTag("NoSuchEntityException", () => Effect.void),
924 );
925 yield* iam
926 .deleteRole({ RoleName: roleName })
927 .pipe(
928 Effect.catchTag("NoSuchEntityException", () => Effect.void),
929 );
930 }
931 }),
932 });
933 }),
934 );