| 1 | import * as AWS from "@/AWS"; |
| 2 | import { BackupPlan } from "@/AWS/Backup/BackupPlan.ts"; |
| 3 | import { BackupSelection } from "@/AWS/Backup/BackupSelection.ts"; |
| 4 | import { BackupVault } from "@/AWS/Backup/BackupVault.ts"; |
| 5 | import { |
| 6 | normalizePolicyDocument, |
| 7 | type PolicyDocument, |
| 8 | } from "@/AWS/IAM/Policy.ts"; |
| 9 | import * as Provider from "@/Provider"; |
| 10 | import * as Test from "@/Test/Alchemy"; |
| 11 | import * as backup from "@distilled.cloud/aws/backup"; |
| 12 | import { expect } from "alchemy-test"; |
| 13 | import * as Effect from "effect/Effect"; |
| 14 | |
| 15 | const { test } = Test.make({ providers: AWS.providers() }); |
| 16 | |
| 17 | const 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. |
| 24 | const 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. |
| 42 | const getVault = backup |
| 43 | .describeBackupVault({ BackupVaultName: vaultName }) |
| 44 | .pipe( |
| 45 | Effect.catchTag( |
| 46 | ["ResourceNotFoundException", "AccessDeniedException"], |
| 47 | () => Effect.succeed(undefined), |
| 48 | ), |
| 49 | ); |
| 50 | |
| 51 | test.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 | ); |