1import * as AWS from "@/AWS";
2import { BackupPlan } from "@/AWS/Backup/BackupPlan.ts";
3import { BackupSelection } from "@/AWS/Backup/BackupSelection.ts";
4import { BackupVault } from "@/AWS/Backup/BackupVault.ts";
5import {
6 normalizePolicyDocument,
7 type PolicyDocument,
8} from "@/AWS/IAM/Policy.ts";
9import * as Provider from "@/Provider";
10import * as Test from "@/Test/Alchemy";
11import * as backup from "@distilled.cloud/aws/backup";
12import { expect } from "alchemy-test";
13import * as Effect from "effect/Effect";
14
15const { test } = Test.make({ providers: AWS.providers() });
16
17const vaultName = "alchemy-test-backup-vault-lifecycle";
18
19// A structured PolicyDocument (not a JSON string) — proves the typed prop
20// deploys and that a re-deploy of the equivalent document is drift-free.
21// NOTE: two actions on purpose — AWS Backup canonicalizes a single-element
22// Action array to a bare string in the stored document, which would defeat
23// a verbatim round-trip comparison.
24const vaultAccessPolicy: PolicyDocument = {
25 Version: "2012-10-17",
26 Statement: [
27 {
28 Sid: "DenyRecoveryPointDeletion",
29 Effect: "Deny",
30 Principal: { AWS: "*" },
31 Action: [
32 "backup:DeleteRecoveryPoint",
33 "backup:UpdateRecoveryPointLifecycle",
34 ],
35 Resource: "*",
36 },
37 ],
38};
39
40// AWS Backup reports a missing vault as AccessDeniedException, not
41// ResourceNotFoundException.
42const getVault = backup
43 .describeBackupVault({ BackupVaultName: vaultName })
44 .pipe(
45 Effect.catchTag(
46 ["ResourceNotFoundException", "AccessDeniedException"],
47 () => Effect.succeed(undefined),
48 ),
49 );
50
51test.provider(
52 "lifecycle: vault + plan + selection, update plan, destroy",
53 (stack) =>
54 Effect.gen(function* () {
55 yield* stack.destroy();
56
57 // Create — vault, an IAM role AWS Backup can assume, a plan targeting the
58 // vault, and a tag-based selection assigned to that plan.
59 const deployed = yield* stack.deploy(
60 Effect.gen(function* () {
61 const vault = yield* BackupVault("LifecycleVault", {
62 backupVaultName: vaultName,
63 accessPolicy: vaultAccessPolicy,
64 tags: { env: "test" },
65 });
66 const role = yield* AWS.IAM.Role("LifecycleBackupRole", {
67 assumeRolePolicyDocument: {
68 Version: "2012-10-17",
69 Statement: [
70 {
71 Effect: "Allow",
72 Principal: { Service: "backup.amazonaws.com" },
73 Action: ["sts:AssumeRole"],
74 },
75 ],
76 },
77 managedPolicyArns: [
78 "arn:aws:iam::aws:policy/service-role/AWSBackupServiceRolePolicyForBackup",
79 ],
80 });
81 const plan = yield* BackupPlan("LifecyclePlan", {
82 rules: [
83 {
84 ruleName: "DailyBackups",
85 targetBackupVaultName: vault.backupVaultName,
86 scheduleExpression: "cron(0 5 ? * * *)",
87 startWindow: "1 hour",
88 completionWindow: "180 minutes",
89 lifecycle: { deleteAfter: "30 days" },
90 },
91 ],
92 tags: { env: "test" },
93 });
94 const selection = yield* BackupSelection("LifecycleSelection", {
95 backupPlanId: plan.backupPlanId,
96 iamRoleArn: role.roleArn,
97 listOfTags: [
98 {
99 conditionType: "STRINGEQUALS",
100 conditionKey: "aws:ResourceTag/backup",
101 conditionValue: "daily",
102 },
103 ],
104 });
105 return {
106 vaultArn: vault.backupVaultArn,
107 planId: plan.backupPlanId,
108 planName: plan.backupPlanName,
109 selectionId: selection.selectionId,
110 };
111 }),
112 );
113
114 expect(deployed.vaultArn).toContain(`backup-vault:${vaultName}`);
115
116 // Out-of-band verification via distilled.
117 const vault = yield* getVault;
118 expect(vault?.BackupVaultName).toBe(vaultName);
119
120 const plan = yield* backup.getBackupPlan({
121 BackupPlanId: deployed.planId,
122 });
123 expect(plan.BackupPlan?.BackupPlanName).toBe(deployed.planName);
124 expect(plan.BackupPlan?.Rules?.[0]?.RuleName).toBe("DailyBackups");
125 expect(plan.BackupPlan?.Rules?.[0]?.Lifecycle?.DeleteAfterDays).toBe(30);
126
127 const selection = yield* backup.getBackupSelection({
128 BackupPlanId: deployed.planId,
129 SelectionId: deployed.selectionId,
130 });
131 expect(selection.BackupSelection?.ListOfTags?.[0]?.ConditionKey).toBe(
132 "aws:ResourceTag/backup",
133 );
134
135 // Vault tags applied.
136 const vaultTags = yield* backup.listTags({
137 ResourceArn: deployed.vaultArn,
138 });
139 expect(vaultTags.Tags?.env).toBe("test");
140
141 // The PolicyDocument-valued access policy round-trips: the document
142 // AWS stored is canonically equal to the one we deployed.
143 const storedPolicy = yield* backup.getBackupVaultAccessPolicy({
144 BackupVaultName: vaultName,
145 });
146 expect(storedPolicy.Policy).toBeDefined();
147 expect(normalizePolicyDocument(storedPolicy.Policy!)).toBe(
148 normalizePolicyDocument(vaultAccessPolicy),
149 );
150
151 // Canonical list() coverage.
152 const vaultProvider = yield* Provider.findProvider(BackupVault);
153 const allVaults = yield* vaultProvider.list();
154 expect(allVaults.some((v) => v.backupVaultName === vaultName)).toBe(true);
155
156 // Update the plan in place — change retention and start window. The
157 // vault re-deploys with an equivalent PolicyDocument (statement keys
158 // reordered) plus a tag change, so its reconcile runs and the policy
159 // sync must conclude "no drift" against the stored document.
160 yield* stack.deploy(
161 Effect.gen(function* () {
162 const vault = yield* BackupVault("LifecycleVault", {
163 backupVaultName: vaultName,
164 accessPolicy: {
165 Statement: [
166 {
167 Resource: "*",
168 Action: [
169 "backup:DeleteRecoveryPoint",
170 "backup:UpdateRecoveryPointLifecycle",
171 ],
172 Principal: { AWS: "*" },
173 Effect: "Deny",
174 Sid: "DenyRecoveryPointDeletion",
175 },
176 ],
177 Version: "2012-10-17",
178 },
179 tags: { env: "test", phase: "two" },
180 });
181 const role = yield* AWS.IAM.Role("LifecycleBackupRole", {
182 assumeRolePolicyDocument: {
183 Version: "2012-10-17",
184 Statement: [
185 {
186 Effect: "Allow",
187 Principal: { Service: "backup.amazonaws.com" },
188 Action: ["sts:AssumeRole"],
189 },
190 ],
191 },
192 managedPolicyArns: [
193 "arn:aws:iam::aws:policy/service-role/AWSBackupServiceRolePolicyForBackup",
194 ],
195 });
196 const plan = yield* BackupPlan("LifecyclePlan", {
197 rules: [
198 {
199 ruleName: "DailyBackups",
200 targetBackupVaultName: vault.backupVaultName,
201 scheduleExpression: "cron(0 6 ? * * *)",
202 startWindow: "2 hours",
203 completionWindow: "6 hours",
204 lifecycle: { deleteAfter: "60 days" },
205 },
206 ],
207 tags: { env: "test" },
208 });
209 yield* BackupSelection("LifecycleSelection", {
210 backupPlanId: plan.backupPlanId,
211 iamRoleArn: role.roleArn,
212 listOfTags: [
213 {
214 conditionType: "STRINGEQUALS",
215 conditionKey: "aws:ResourceTag/backup",
216 conditionValue: "daily",
217 },
218 ],
219 });
220 return { planId: plan.backupPlanId };
221 }),
222 );
223
224 const updatedPlan = yield* backup.getBackupPlan({
225 BackupPlanId: deployed.planId,
226 });
227 expect(
228 updatedPlan.BackupPlan?.Rules?.[0]?.Lifecycle?.DeleteAfterDays,
229 ).toBe(60);
230 expect(updatedPlan.BackupPlan?.Rules?.[0]?.CompletionWindowMinutes).toBe(
231 360,
232 );
233
234 // Re-deploy was clean: the stored access policy is still canonically
235 // equal to the original document, and the tag update landed.
236 const policyAfterRedeploy = yield* backup.getBackupVaultAccessPolicy({
237 BackupVaultName: vaultName,
238 });
239 expect(normalizePolicyDocument(policyAfterRedeploy.Policy!)).toBe(
240 normalizePolicyDocument(vaultAccessPolicy),
241 );
242 const tagsAfterRedeploy = yield* backup.listTags({
243 ResourceArn: deployed.vaultArn,
244 });
245 expect(tagsAfterRedeploy.Tags?.phase).toBe("two");
246
247 // Destroy — provider recursively deletes and everything is gone.
248 yield* stack.destroy();
249 const afterVault = yield* getVault;
250 expect(afterVault).toBeUndefined();
251
252 // The plan no longer appears in the account listing.
253 const planProvider = yield* Provider.findProvider(BackupPlan);
254 const remainingPlans = yield* planProvider.list();
255 expect(
256 remainingPlans.some((p) => p.backupPlanId === deployed.planId),
257 ).toBe(false);
258 }),
259 {
260 tags: ["provider:aws", "provider:aws:backup", "provider:aws:iam", "live"],
261 timeout: 240_000,
262 },
263);