1import * as AWS from "@/AWS";
2import { LifecyclePolicy, type LifecyclePolicyDetails } from "@/AWS/DLM";
3import * as IAM from "@/AWS/IAM";
4import * as Test from "@/Test/Alchemy";
5import * as dlm from "@distilled.cloud/aws/dlm";
6import * as iam from "@distilled.cloud/aws/iam";
7import { expect } from "alchemy-test";
8import * as Data from "effect/Data";
9import * as Effect from "effect/Effect";
10import * as Schedule from "effect/Schedule";
11
12const { test } = Test.make({ providers: AWS.providers() });
13
14class PolicyStillExists extends Data.TaggedError("PolicyStillExists")<{
15 readonly policyId: string;
16}> {}
17
18const assertPolicyDeleted = (policyId: string) =>
19 dlm.getLifecyclePolicy({ PolicyId: policyId }).pipe(
20 Effect.flatMap(() => Effect.fail(new PolicyStillExists({ policyId }))),
21 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
22 Effect.retry({
23 while: (e) => e._tag === "PolicyStillExists",
24 schedule: Schedule.max([
25 Schedule.fixed("2 seconds"),
26 Schedule.recurs(10),
27 ]),
28 }),
29 );
30
31class RoleStillExists extends Data.TaggedError("RoleStillExists")<{
32 readonly roleName: string;
33}> {}
34
35const assertRoleDeleted = (roleName: string) =>
36 iam.getRole({ RoleName: roleName }).pipe(
37 Effect.flatMap(() => Effect.fail(new RoleStillExists({ roleName }))),
38 Effect.catchTag("NoSuchEntityException", () => Effect.void),
39 Effect.retry({
40 while: (e) => e._tag === "RoleStillExists",
41 schedule: Schedule.max([
42 Schedule.fixed("2 seconds"),
43 Schedule.recurs(10),
44 ]),
45 }),
46 );
47
48const tagRecord = (
49 tags: { [key: string]: string | undefined } | undefined,
50): Record<string, string | undefined> => tags ?? {};
51
52const snapshotDetails = (retainCount: number) =>
53 ({
54 resourceTypes: ["VOLUME"],
55 targetTags: { AlchemyDlmTest: "true" },
56 schedules: [
57 {
58 name: "Daily",
59 copyTags: false,
60 createRule: {
61 interval: 24,
62 intervalUnit: "HOURS",
63 times: ["03:00"],
64 },
65 retainRule: { count: retainCount },
66 },
67 ],
68 }) satisfies LifecyclePolicyDetails;
69
70test.provider(
71 "create, update, no-op, tag removal, destroy with managed role",
72 (stack) =>
73 Effect.gen(function* () {
74 const policy = yield* stack.deploy(
75 Effect.gen(function* () {
76 return yield* LifecyclePolicy("Snapshots", {
77 policyDetails: snapshotDetails(7),
78 tags: { Environment: "test" },
79 });
80 }),
81 );
82
83 expect(policy.policyId).toMatch(/^policy-/);
84 expect(policy.policyArn).toContain(":policy/");
85 expect(policy.state).toBe("ENABLED");
86 expect(policy.executionRoleArn).toContain(":role/");
87 expect(policy.roleName).toBeDefined();
88
89 // out-of-band verification via distilled
90 const created = yield* dlm
91 .getLifecyclePolicy({ PolicyId: policy.policyId })
92 .pipe(Effect.map((r) => r.Policy!));
93 expect(created.State).toBe("ENABLED");
94 expect(created.ExecutionRoleArn).toBe(policy.executionRoleArn);
95 expect(created.PolicyDetails?.PolicyType).toBe("EBS_SNAPSHOT_MANAGEMENT");
96 expect(created.PolicyDetails?.ResourceTypes).toEqual(["VOLUME"]);
97 expect(created.PolicyDetails?.TargetTags).toEqual([
98 { Key: "AlchemyDlmTest", Value: "true" },
99 ]);
100 expect(created.PolicyDetails?.Schedules?.[0]?.RetainRule?.Count).toBe(7);
101 const createdTags = tagRecord(created.Tags);
102 expect(createdTags.Environment).toBe("test");
103 expect(createdTags["alchemy::id"]).toBe("Snapshots");
104
105 // update retention + state + add a tag — same physical policy
106 const updated = yield* stack.deploy(
107 Effect.gen(function* () {
108 return yield* LifecyclePolicy("Snapshots", {
109 state: "DISABLED",
110 policyDetails: snapshotDetails(14),
111 tags: { Environment: "test", Extra: "1" },
112 });
113 }),
114 );
115 expect(updated.policyId).toBe(policy.policyId);
116 expect(updated.state).toBe("DISABLED");
117
118 const afterUpdate = yield* dlm
119 .getLifecyclePolicy({ PolicyId: policy.policyId })
120 .pipe(Effect.map((r) => r.Policy!));
121 expect(afterUpdate.State).toBe("DISABLED");
122 expect(afterUpdate.PolicyDetails?.Schedules?.[0]?.RetainRule?.Count).toBe(
123 14,
124 );
125 expect(tagRecord(afterUpdate.Tags).Extra).toBe("1");
126 const modifiedAfterUpdate = afterUpdate.DateModified?.toISOString();
127
128 // no-op deploy converges without issuing an update
129 yield* stack.deploy(
130 Effect.gen(function* () {
131 return yield* LifecyclePolicy("Snapshots", {
132 state: "DISABLED",
133 policyDetails: snapshotDetails(14),
134 tags: { Environment: "test", Extra: "1" },
135 });
136 }),
137 );
138 const afterNoop = yield* dlm
139 .getLifecyclePolicy({ PolicyId: policy.policyId })
140 .pipe(Effect.map((r) => r.Policy!));
141 expect(afterNoop.DateModified?.toISOString()).toBe(modifiedAfterUpdate);
142
143 // remove a tag
144 yield* stack.deploy(
145 Effect.gen(function* () {
146 return yield* LifecyclePolicy("Snapshots", {
147 state: "DISABLED",
148 policyDetails: snapshotDetails(14),
149 tags: { Environment: "test" },
150 });
151 }),
152 );
153 const afterTagRemoval = yield* dlm
154 .getLifecyclePolicy({ PolicyId: policy.policyId })
155 .pipe(Effect.map((r) => tagRecord(r.Policy?.Tags)));
156 expect(afterTagRemoval.Extra).toBeUndefined();
157 expect(afterTagRemoval.Environment).toBe("test");
158 expect(afterTagRemoval["alchemy::id"]).toBe("Snapshots");
159
160 yield* stack.destroy();
161 yield* assertPolicyDeleted(policy.policyId);
162 yield* assertRoleDeleted(policy.roleName!);
163 }),
164 {
165 tags: ["provider:aws", "provider:aws:dlm", "provider:aws:iam", "live"],
166 timeout: 120_000,
167 },
168);
169
170test.provider(
171 "explicit execution role; policy type change triggers replacement",
172 (stack) =>
173 Effect.gen(function* () {
174 const deployWith = (policyDetails: LifecyclePolicyDetails) =>
175 stack.deploy(
176 Effect.gen(function* () {
177 const role = yield* IAM.Role("DlmRole", {
178 assumeRolePolicyDocument: {
179 Version: "2012-10-17",
180 Statement: [
181 {
182 Effect: "Allow",
183 Principal: { Service: "dlm.amazonaws.com" },
184 Action: ["sts:AssumeRole"],
185 },
186 ],
187 },
188 managedPolicyArns: [
189 "arn:aws:iam::aws:policy/service-role/AWSDataLifecycleManagerServiceRole",
190 "arn:aws:iam::aws:policy/service-role/AWSDataLifecycleManagerServiceRoleForAMIManagement",
191 ],
192 });
193 const policy = yield* LifecyclePolicy("Policy", {
194 description: "alchemy dlm explicit role test",
195 state: "DISABLED",
196 executionRoleArn: role.roleArn,
197 policyDetails,
198 });
199 return { role, policy };
200 }),
201 );
202
203 const first = yield* deployWith(snapshotDetails(2));
204 expect(first.policy.state).toBe("DISABLED");
205 expect(first.policy.roleName).toBeUndefined();
206 expect(first.policy.executionRoleArn).toBe(first.role.roleArn);
207
208 const observed = yield* dlm
209 .getLifecyclePolicy({ PolicyId: first.policy.policyId })
210 .pipe(Effect.map((r) => r.Policy!));
211 expect(observed.State).toBe("DISABLED");
212 expect(observed.Description).toBe("alchemy dlm explicit role test");
213 expect(observed.ExecutionRoleArn).toBe(first.role.roleArn);
214
215 // changing the policy type replaces the policy (new PolicyId). The
216 // role stays deployed across the replacement step.
217 const second = yield* deployWith({
218 policyType: "IMAGE_MANAGEMENT",
219 resourceTypes: ["INSTANCE"],
220 targetTags: { AlchemyDlmTest: "ami" },
221 parameters: { noReboot: true },
222 schedules: [
223 {
224 name: "Nightly",
225 createRule: { interval: 24, intervalUnit: "HOURS" },
226 retainRule: { count: 2 },
227 },
228 ],
229 });
230 expect(second.policy.policyId).not.toBe(first.policy.policyId);
231
232 const observedAmi = yield* dlm
233 .getLifecyclePolicy({ PolicyId: second.policy.policyId })
234 .pipe(Effect.map((r) => r.Policy!));
235 expect(observedAmi.PolicyDetails?.PolicyType).toBe("IMAGE_MANAGEMENT");
236 expect(observedAmi.PolicyDetails?.Parameters?.NoReboot).toBe(true);
237 yield* assertPolicyDeleted(first.policy.policyId);
238
239 yield* stack.destroy();
240 yield* assertPolicyDeleted(second.policy.policyId);
241 }),
242 {
243 tags: ["provider:aws", "provider:aws:dlm", "provider:aws:iam", "live"],
244 timeout: 180_000,
245 },
246);