1import * as backup from "@distilled.cloud/aws/backup";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.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 { toWireDays, toWireMinutes } from "../../Util/Duration.ts";
12import type { Providers } from "../Providers.ts";
13
14/**
15 * Lifecycle policy controlling when a recovery point transitions to cold
16 * storage and when it is deleted.
17 */
18export interface BackupRuleLifecycle {
19 /**
20 * Time after creation that a recovery point is moved to cold storage,
21 * e.g. `"30 days"` (whole days on the wire). Must be at least 90 days
22 * less than `deleteAfter`.
23 */
24 moveToColdStorageAfter?: Duration.Input;
25 /**
26 * Time after creation that a recovery point is deleted, e.g. `"365 days"`
27 * (whole days on the wire).
28 */
29 deleteAfter?: Duration.Input;
30 /**
31 * Opt recovery points of supported resources into archive-tier storage.
32 */
33 optInToArchiveForSupportedResources?: boolean;
34}
35
36/**
37 * A single scheduled backup rule within a backup plan.
38 */
39export interface BackupPlanRule {
40 /**
41 * Display name for the rule. Must be unique within the plan.
42 */
43 ruleName: string;
44 /**
45 * Name of the target backup vault where recovery points are stored.
46 */
47 targetBackupVaultName: string;
48 /**
49 * CRON expression (in UTC) specifying when the backup is taken, e.g.
50 * `cron(0 5 ? * * *)`. Omit for continuous backup or on-demand plans.
51 */
52 scheduleExpression?: string;
53 /**
54 * Timezone the schedule expression is evaluated in.
55 */
56 scheduleExpressionTimezone?: string;
57 /**
58 * Time after the scheduled time within which a backup must start, or it
59 * is canceled, e.g. `"1 hour"` (whole minutes on the wire).
60 */
61 startWindow?: Duration.Input;
62 /**
63 * Time within which a backup must complete, or it is canceled, e.g.
64 * `"3 hours"` (whole minutes on the wire).
65 */
66 completionWindow?: Duration.Input;
67 /**
68 * Enables continuous backups (point-in-time restore) for supported
69 * resources.
70 */
71 enableContinuousBackup?: boolean;
72 /**
73 * Lifecycle policy for the recovery points created by this rule.
74 */
75 lifecycle?: BackupRuleLifecycle;
76 /**
77 * Tags applied to recovery points created by this rule.
78 */
79 recoveryPointTags?: Record<string, string>;
80}
81
82export interface BackupPlanProps {
83 /**
84 * Display name of the backup plan. If omitted, a unique name is generated
85 * from the app, stage, and logical ID.
86 */
87 backupPlanName?: string;
88 /**
89 * One or more scheduled backup rules that make up the plan.
90 */
91 rules: BackupPlanRule[];
92 /**
93 * Tags to apply to the backup plan. Merged with internal Alchemy tags.
94 */
95 tags?: Record<string, string>;
96}
97
98export interface BackupPlan extends Resource<
99 "AWS.Backup.BackupPlan",
100 BackupPlanProps,
101 {
102 /**
103 * Service-assigned unique ID of the backup plan.
104 */
105 backupPlanId: string;
106 /**
107 * ARN of the backup plan.
108 */
109 backupPlanArn: string;
110 /**
111 * Name of the backup plan.
112 */
113 backupPlanName: string;
114 /**
115 * Version ID of the plan; updated each time the plan document changes.
116 */
117 versionId: string;
118 },
119 never,
120 Providers
121> {}
122
123/**
124 * An AWS Backup plan — a policy expression that defines *when* and *how* you
125 * want to back up your resources, composed of one or more scheduled rules
126 * that each target a backup vault.
127 *
128 * Pair a plan with a {@link BackupSelection} to assign the AWS resources it
129 * protects.
130 *
131 * ### Creating a Plan
132 * **Example:** Daily backups retained for 30 days
133 * ```typescript
134 * import * as Backup from "alchemy/AWS/Backup";
135 *
136 * const vault = yield* Backup.BackupVault("AppBackups");
137 *
138 * const plan = yield* Backup.BackupPlan("DailyPlan", {
139 * rules: [
140 * {
141 * ruleName: "DailyBackups",
142 * targetBackupVaultName: vault.backupVaultName,
143 * scheduleExpression: "cron(0 5 ? * * *)",
144 * startWindow: "1 hour",
145 * completionWindow: "3 hours",
146 * lifecycle: { deleteAfter: "30 days" },
147 * },
148 * ],
149 * });
150 * ```
151 *
152 * **Example:** Move to cold storage then delete
153 * ```typescript
154 * const plan = yield* Backup.BackupPlan("ArchivePlan", {
155 * rules: [
156 * {
157 * ruleName: "MonthlyArchive",
158 * targetBackupVaultName: vault.backupVaultName,
159 * scheduleExpression: "cron(0 5 1 * ? *)",
160 * lifecycle: {
161 * moveToColdStorageAfter: "30 days",
162 * deleteAfter: "365 days",
163 * },
164 * },
165 * ],
166 * });
167 * ```
168 *
169 * @resource
170 */
171export const BackupPlan = Resource<BackupPlan>("AWS.Backup.BackupPlan");
172
173const toRuleInput = (rule: BackupPlanRule): backup.BackupRuleInput => ({
174 RuleName: rule.ruleName,
175 TargetBackupVaultName: rule.targetBackupVaultName,
176 ScheduleExpression: rule.scheduleExpression,
177 ScheduleExpressionTimezone: rule.scheduleExpressionTimezone,
178 StartWindowMinutes: toWireMinutes(rule.startWindow),
179 CompletionWindowMinutes: toWireMinutes(rule.completionWindow),
180 EnableContinuousBackup: rule.enableContinuousBackup,
181 RecoveryPointTags: rule.recoveryPointTags,
182 Lifecycle: rule.lifecycle
183 ? {
184 MoveToColdStorageAfterDays: toWireDays(
185 rule.lifecycle.moveToColdStorageAfter,
186 ),
187 DeleteAfterDays: toWireDays(rule.lifecycle.deleteAfter),
188 OptInToArchiveForSupportedResources:
189 rule.lifecycle.optInToArchiveForSupportedResources,
190 }
191 : undefined,
192});
193
194export const BackupPlanProvider = () =>
195 Provider.effect(
196 BackupPlan,
197 Effect.gen(function* () {
198 const createName = Effect.fn(function* (
199 id: string,
200 props: { backupPlanName?: string | undefined },
201 ) {
202 return (
203 props.backupPlanName ??
204 (yield* createPhysicalName({ id, maxLength: 50 }))
205 );
206 });
207
208 return BackupPlan.Provider.of({
209 stables: ["backupPlanId", "backupPlanArn"],
210 list: () =>
211 backup.listBackupPlans.pages({}).pipe(
212 Stream.runCollect,
213 Effect.map((chunk) =>
214 Array.from(chunk)
215 .flatMap((page) => page.BackupPlansList ?? [])
216 .filter(
217 (p) =>
218 p.BackupPlanId !== undefined &&
219 p.BackupPlanArn !== undefined &&
220 // Service-managed plans (e.g. EFS automatic backups'
221 // `aws/efs/automatic-backup-plan`) reject DeleteBackupPlan
222 // with AccessDenied (verified live) — keep them out of
223 // enumeration for account-wide teardown (nuke).
224 !(p.BackupPlanName ?? "").startsWith("aws/"),
225 )
226 .map((p) => ({
227 backupPlanId: p.BackupPlanId!,
228 backupPlanArn: p.BackupPlanArn!,
229 backupPlanName: p.BackupPlanName ?? "",
230 versionId: p.VersionId ?? "",
231 })),
232 ),
233 ),
234 read: Effect.fn(function* ({ id, output }) {
235 if (!output?.backupPlanId) return undefined;
236 const found = yield* backup
237 .getBackupPlan({ BackupPlanId: output.backupPlanId })
238 .pipe(
239 Effect.catchTag("ResourceNotFoundException", () =>
240 Effect.succeed(undefined),
241 ),
242 );
243 if (!found?.BackupPlanArn) return undefined;
244 const attrs = {
245 backupPlanId: output.backupPlanId,
246 backupPlanArn: found.BackupPlanArn,
247 backupPlanName:
248 found.BackupPlan?.BackupPlanName ?? output.backupPlanName,
249 versionId: found.VersionId ?? output.versionId,
250 };
251 const tags = yield* backup
252 .listTags({ ResourceArn: found.BackupPlanArn })
253 .pipe(
254 Effect.map((r) => r.Tags ?? {}),
255 Effect.catch(() =>
256 Effect.succeed({} as Record<string, string | undefined>),
257 ),
258 );
259 return (yield* hasAlchemyTags(id, tags as Record<string, string>))
260 ? attrs
261 : Unowned(attrs);
262 }),
263 reconcile: Effect.fn(function* ({ id, news = {}, output, session }) {
264 const name = output?.backupPlanName ?? (yield* createName(id, news));
265 const internalTags = yield* createInternalTags(id);
266 const backupPlanInput: backup.BackupPlanInput = {
267 BackupPlanName: name,
268 Rules: (news.rules ?? []).map(toRuleInput),
269 };
270
271 // OBSERVE — resolve the plan by its stable id when we have one.
272 const live = output?.backupPlanId
273 ? yield* backup
274 .getBackupPlan({ BackupPlanId: output.backupPlanId })
275 .pipe(
276 Effect.catchTag("ResourceNotFoundException", () =>
277 Effect.succeed(undefined),
278 ),
279 )
280 : undefined;
281
282 let backupPlanId: string;
283 let backupPlanArn: string;
284 let versionId: string;
285
286 if (!live?.BackupPlanArn) {
287 // ENSURE — create.
288 const created = yield* backup.createBackupPlan({
289 BackupPlan: backupPlanInput,
290 BackupPlanTags: { ...internalTags, ...news.tags },
291 });
292 backupPlanId = created.BackupPlanId!;
293 backupPlanArn = created.BackupPlanArn!;
294 versionId = created.VersionId ?? "";
295 } else {
296 // SYNC — a plan is a true upsert via updateBackupPlan; converge
297 // rule/name changes unconditionally (the API is idempotent).
298 backupPlanId = output!.backupPlanId!;
299 const updated = yield* backup.updateBackupPlan({
300 BackupPlanId: backupPlanId,
301 BackupPlan: backupPlanInput,
302 });
303 backupPlanArn = updated.BackupPlanArn ?? live.BackupPlanArn;
304 versionId = updated.VersionId ?? live.VersionId ?? "";
305 }
306
307 // SYNC tags — diff against observed cloud tags.
308 const currentTags = yield* backup
309 .listTags({ ResourceArn: backupPlanArn })
310 .pipe(
311 Effect.map((r) => r.Tags ?? {}),
312 Effect.catch(() =>
313 Effect.succeed({} as Record<string, string | undefined>),
314 ),
315 );
316 const { upsert, removed } = diffTags(
317 currentTags as Record<string, string>,
318 { ...news.tags, ...internalTags },
319 );
320 if (upsert.length > 0) {
321 yield* backup.tagResource({
322 ResourceArn: backupPlanArn,
323 Tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
324 });
325 }
326 if (removed.length > 0) {
327 yield* backup.untagResource({
328 ResourceArn: backupPlanArn,
329 TagKeyList: removed,
330 });
331 }
332
333 yield* session.note(backupPlanId);
334 return {
335 backupPlanId,
336 backupPlanArn,
337 backupPlanName: name,
338 versionId,
339 };
340 }),
341 delete: Effect.fn(function* ({ output }) {
342 // A plan cannot be deleted while it still has resource selections
343 // attached — delete those first (idempotent: a vanished selection
344 // is already gone).
345 const selections = yield* backup.listBackupSelections
346 .pages({ BackupPlanId: output.backupPlanId })
347 .pipe(
348 Stream.runCollect,
349 Effect.map((chunk) =>
350 Array.from(chunk).flatMap(
351 (page) => page.BackupSelectionsList ?? [],
352 ),
353 ),
354 Effect.catchTag("ResourceNotFoundException", () =>
355 Effect.succeed([]),
356 ),
357 );
358 yield* Effect.forEach(
359 selections,
360 (s) =>
361 s.SelectionId
362 ? backup
363 .deleteBackupSelection({
364 BackupPlanId: output.backupPlanId,
365 SelectionId: s.SelectionId,
366 })
367 .pipe(
368 Effect.catchTag(
369 "ResourceNotFoundException",
370 () => Effect.void,
371 ),
372 )
373 : Effect.void,
374 { discard: true },
375 );
376 yield* backup
377 .deleteBackupPlan({ BackupPlanId: output.backupPlanId })
378 .pipe(
379 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
380 // Selection deletion is eventually consistent; the plan delete
381 // rejects with InvalidRequestException until it settles.
382 Effect.retry({
383 while: (e) => e._tag === "InvalidRequestException",
384 schedule: Schedule.max([
385 Schedule.fixed("3 seconds"),
386 Schedule.recurs(10),
387 ]),
388 }),
389 );
390 }),
391 });
392 }),
393 );