1import * as backup from "@distilled.cloud/aws/backup";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
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 {
12 normalizePolicyDocument,
13 stringifyPolicyDocument,
14 type PolicyDocument,
15} from "../IAM/Policy.ts";
16import type { Providers } from "../Providers.ts";
17
18export interface BackupVaultProps {
19 /**
20 * Name of the backup vault. Must be between 2 and 50 characters and
21 * contain only letters, numbers, hyphens, and underscores. If omitted, a
22 * unique name is generated from the app, stage, and logical ID.
23 *
24 * Changing the name replaces the vault.
25 */
26 backupVaultName?: string;
27 /**
28 * ARN of the KMS key used to encrypt the recovery points stored in this
29 * vault. If omitted, AWS Backup uses an AWS-owned key.
30 *
31 * The encryption key is fixed at creation — changing it replaces the vault.
32 */
33 encryptionKeyArn?: string;
34 /**
35 * Resource-based access policy attached to the vault. Provided as a
36 * structured {@link PolicyDocument} (typed actions, drift-normalized on
37 * re-deploy) or a raw JSON string escape hatch. Omit to leave the vault
38 * without a resource policy.
39 */
40 accessPolicy?: PolicyDocument | string;
41 /**
42 * Tags to apply to the vault. Merged with internal Alchemy tags.
43 */
44 tags?: Record<string, string>;
45}
46
47export interface BackupVault extends Resource<
48 "AWS.Backup.BackupVault",
49 BackupVaultProps,
50 {
51 /**
52 * Name of the backup vault.
53 */
54 backupVaultName: string;
55 /**
56 * ARN of the backup vault.
57 */
58 backupVaultArn: string;
59 },
60 never,
61 Providers
62> {}
63
64/**
65 * An AWS Backup vault — a logical container that stores and organizes
66 * recovery points created by backup jobs.
67 *
68 * A vault name is auto-generated from the app, stage, and logical ID unless
69 * you provide one explicitly. Recovery points can optionally be encrypted
70 * with a customer-managed KMS key, and access to the vault can be restricted
71 * with a resource-based policy.
72 *
73 * ### Creating a Vault
74 * **Example:** Basic Vault
75 * ```typescript
76 * import * as Backup from "alchemy/AWS/Backup";
77 *
78 * const vault = yield* Backup.BackupVault("AppBackups");
79 * ```
80 *
81 * **Example:** Vault with a Customer-Managed KMS Key
82 * ```typescript
83 * const vault = yield* Backup.BackupVault("EncryptedBackups", {
84 * encryptionKeyArn: key.keyArn,
85 * });
86 * ```
87 *
88 * ### Vault Access Policy
89 * **Example:** Deny deletion of recovery points
90 * ```typescript
91 * const vault = yield* Backup.BackupVault("LockedBackups", {
92 * accessPolicy: {
93 * Version: "2012-10-17",
94 * Statement: [
95 * {
96 * Effect: "Deny",
97 * Principal: { AWS: "*" },
98 * Action: ["backup:DeleteRecoveryPoint"],
99 * Resource: "*",
100 * },
101 * ],
102 * },
103 * });
104 * ```
105 *
106 * @resource
107 */
108export const BackupVault = Resource<BackupVault>("AWS.Backup.BackupVault");
109
110const toPolicyString = (
111 policy: PolicyDocument | string | undefined,
112): string | undefined =>
113 policy === undefined
114 ? undefined
115 : typeof policy === "string"
116 ? policy
117 : stringifyPolicyDocument(policy);
118
119export const BackupVaultProvider = () =>
120 Provider.effect(
121 BackupVault,
122 Effect.gen(function* () {
123 const createName = Effect.fn(function* (
124 id: string,
125 props: BackupVaultProps,
126 ) {
127 return (
128 props.backupVaultName ??
129 (yield* createPhysicalName({ id, maxLength: 50 }))
130 );
131 });
132
133 return BackupVault.Provider.of({
134 stables: ["backupVaultName", "backupVaultArn"],
135 list: () =>
136 backup.listBackupVaults.pages({}).pipe(
137 Stream.runCollect,
138 Effect.map((chunk) =>
139 Array.from(chunk)
140 .flatMap((page) => page.BackupVaultList ?? [])
141 .filter(
142 (v) =>
143 v.BackupVaultName !== undefined &&
144 v.BackupVaultArn !== undefined &&
145 // Service-managed vaults (e.g. EFS automatic backups'
146 // `aws/efs/automatic-backup-vault`) reject
147 // DeleteBackupVault with a 403 even when empty (verified
148 // live) — keep them out of enumeration for account-wide
149 // teardown (nuke).
150 !v.BackupVaultName.startsWith("aws/"),
151 )
152 .map((v) => ({
153 backupVaultName: v.BackupVaultName!,
154 backupVaultArn: v.BackupVaultArn!,
155 })),
156 ),
157 ),
158 read: Effect.fn(function* ({ id, olds, output }) {
159 const name =
160 output?.backupVaultName ?? (yield* createName(id, olds ?? {}));
161 // AWS Backup returns AccessDeniedException (with an empty message),
162 // not ResourceNotFoundException, when a vault does not exist — it
163 // hides existence behind a 403. Treat both as "absent".
164 const found = yield* backup
165 .describeBackupVault({ BackupVaultName: name })
166 .pipe(
167 Effect.catchTag(
168 ["ResourceNotFoundException", "AccessDeniedException"],
169 () => Effect.succeed(undefined),
170 ),
171 );
172 if (!found?.BackupVaultArn) return undefined;
173 const attrs = {
174 backupVaultName: name,
175 backupVaultArn: found.BackupVaultArn,
176 };
177 const tags = yield* backup
178 .listTags({ ResourceArn: found.BackupVaultArn })
179 .pipe(
180 Effect.map((r) => r.Tags ?? {}),
181 Effect.catch(() =>
182 Effect.succeed({} as Record<string, string | undefined>),
183 ),
184 );
185 return (yield* hasAlchemyTags(id, tags as Record<string, string>))
186 ? attrs
187 : Unowned(attrs);
188 }),
189 diff: Effect.fn(function* ({ id, news = {}, olds = {} }) {
190 if (!isResolved(news)) return undefined;
191 const oldName = yield* createName(id, olds);
192 const newName = yield* createName(id, news);
193 if (oldName !== newName) return { action: "replace" } as const;
194 if ((olds.encryptionKeyArn ?? "") !== (news.encryptionKeyArn ?? "")) {
195 return { action: "replace" } as const;
196 }
197 }),
198 reconcile: Effect.fn(function* ({ id, news = {}, output, session }) {
199 const name = output?.backupVaultName ?? (yield* createName(id, news));
200 const internalTags = yield* createInternalTags(id);
201
202 // OBSERVE — cloud state is authoritative; output is only an id cache.
203 // A missing vault surfaces as AccessDeniedException (see read()).
204 let live = yield* backup
205 .describeBackupVault({ BackupVaultName: name })
206 .pipe(
207 Effect.catchTag(
208 ["ResourceNotFoundException", "AccessDeniedException"],
209 () => Effect.succeed(undefined),
210 ),
211 );
212
213 // ENSURE — create if missing; tolerate an AlreadyExists race.
214 if (!live?.BackupVaultArn) {
215 live = yield* backup
216 .createBackupVault({
217 BackupVaultName: name,
218 EncryptionKeyArn: news.encryptionKeyArn,
219 BackupVaultTags: { ...internalTags, ...news.tags },
220 })
221 .pipe(
222 Effect.catchTag("AlreadyExistsException", () =>
223 backup.describeBackupVault({ BackupVaultName: name }),
224 ),
225 );
226 }
227 const backupVaultArn = live.BackupVaultArn!;
228
229 // SYNC access policy — diff observed against desired, comparing
230 // canonicalized documents so a re-deploy of an equivalent policy
231 // (key order, whitespace) is a no-op.
232 const desiredPolicy = toPolicyString(news.accessPolicy);
233 const currentPolicy = yield* backup
234 .getBackupVaultAccessPolicy({ BackupVaultName: name })
235 .pipe(
236 Effect.map((r) => (r.Policy ? r.Policy : undefined)),
237 Effect.catchTag("ResourceNotFoundException", () =>
238 Effect.succeed(undefined),
239 ),
240 );
241 const policyDrifted =
242 desiredPolicy === undefined || currentPolicy === undefined
243 ? desiredPolicy !== currentPolicy
244 : normalizePolicyDocument(currentPolicy) !==
245 normalizePolicyDocument(desiredPolicy);
246 if (policyDrifted) {
247 if (desiredPolicy === undefined) {
248 yield* backup
249 .deleteBackupVaultAccessPolicy({ BackupVaultName: name })
250 .pipe(
251 Effect.catchTag(
252 "ResourceNotFoundException",
253 () => Effect.void,
254 ),
255 );
256 } else {
257 yield* backup.putBackupVaultAccessPolicy({
258 BackupVaultName: name,
259 Policy: desiredPolicy,
260 });
261 }
262 }
263
264 // SYNC tags — diff against observed cloud tags.
265 const currentTags = yield* backup
266 .listTags({ ResourceArn: backupVaultArn })
267 .pipe(
268 Effect.map((r) => r.Tags ?? {}),
269 Effect.catch(() =>
270 Effect.succeed({} as Record<string, string | undefined>),
271 ),
272 );
273 const { upsert, removed } = diffTags(
274 currentTags as Record<string, string>,
275 { ...news.tags, ...internalTags },
276 );
277 if (upsert.length > 0) {
278 yield* backup.tagResource({
279 ResourceArn: backupVaultArn,
280 Tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
281 });
282 }
283 if (removed.length > 0) {
284 yield* backup.untagResource({
285 ResourceArn: backupVaultArn,
286 TagKeyList: removed,
287 });
288 }
289
290 yield* session.note(backupVaultArn);
291 return { backupVaultName: name, backupVaultArn };
292 }),
293 delete: Effect.fn(function* ({ output }) {
294 // A vault can only be deleted when it is empty — delete any
295 // remaining recovery points first. Each delete tolerates the point
296 // already being gone or being in a state that can't be deleted
297 // right now (EXPIRED/continuous points settle on their own).
298 const recoveryPoints = yield* backup.listRecoveryPointsByBackupVault
299 .pages({ BackupVaultName: output.backupVaultName })
300 .pipe(
301 Stream.runCollect,
302 Effect.map((chunk) =>
303 Array.from(chunk).flatMap((page) => page.RecoveryPoints ?? []),
304 ),
305 Effect.catchTag(
306 ["ResourceNotFoundException", "AccessDeniedException"],
307 () => Effect.succeed([]),
308 ),
309 );
310 yield* Effect.forEach(
311 recoveryPoints,
312 (rp) =>
313 rp.RecoveryPointArn
314 ? backup
315 .deleteRecoveryPoint({
316 BackupVaultName: output.backupVaultName,
317 RecoveryPointArn: rp.RecoveryPointArn,
318 })
319 .pipe(
320 Effect.catchTag(
321 [
322 "ResourceNotFoundException",
323 "InvalidResourceStateException",
324 ],
325 () => Effect.void,
326 ),
327 )
328 : Effect.void,
329 { discard: true },
330 );
331 // A missing vault deletes as AccessDeniedException, not
332 // ResourceNotFoundException — both mean "already gone" here.
333 yield* backup
334 .deleteBackupVault({ BackupVaultName: output.backupVaultName })
335 .pipe(
336 Effect.catchTag(
337 ["ResourceNotFoundException", "AccessDeniedException"],
338 () => Effect.void,
339 ),
340 // Recovery-point deletion is asynchronous; the vault delete
341 // rejects with InvalidRequestException until the vault empties.
342 Effect.retry({
343 while: (e) => e._tag === "InvalidRequestException",
344 schedule: Schedule.max([
345 Schedule.fixed("3 seconds"),
346 Schedule.recurs(10),
347 ]),
348 }),
349 );
350 }),
351 });
352 }),
353 );