1import * as backup from "@distilled.cloud/aws/backup";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { isResolved } from "../../Diff.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9
10/**
11 * A tag-based condition used to select resources for backup by matching the
12 * tags attached to them.
13 */
14export interface BackupSelectionTagCondition {
15 /**
16 * How the tag is matched. `STRINGEQUALS` is the only supported operator
17 * for the legacy `listOfTags` selector.
18 */
19 conditionType?: "STRINGEQUALS" | (string & {});
20 /**
21 * The tag key to match, e.g. `aws:ResourceTag/backup`.
22 */
23 conditionKey: string;
24 /**
25 * The tag value to match.
26 */
27 conditionValue: string;
28}
29
30export interface BackupSelectionProps {
31 /**
32 * ID of the backup plan this selection assigns resources to. Typically the
33 * `backupPlanId` attribute of a {@link BackupPlan}.
34 *
35 * Changing the plan replaces the selection.
36 */
37 backupPlanId: string;
38 /**
39 * Display name for the selection. If omitted, a unique name is generated
40 * from the app, stage, and logical ID.
41 *
42 * A backup selection is immutable — changing the name replaces it.
43 */
44 selectionName?: string;
45 /**
46 * ARN of the IAM role AWS Backup assumes to create and manage backups on
47 * your behalf. The role's trust policy must allow `backup.amazonaws.com`.
48 *
49 * Immutable — changing the role replaces the selection.
50 */
51 iamRoleArn: string;
52 /**
53 * ARNs of the resources to assign to the backup plan. Use `["*"]` to
54 * assign all supported resources.
55 *
56 * Immutable — changing the resource list replaces the selection.
57 */
58 resources?: string[];
59 /**
60 * ARNs of resources to explicitly exclude from the plan.
61 *
62 * Immutable — changing this replaces the selection.
63 */
64 notResources?: string[];
65 /**
66 * Tag-based conditions selecting resources by their tags (legacy
67 * `ListOfTags` selector — conditions are ANDed together).
68 *
69 * Immutable — changing this replaces the selection.
70 */
71 listOfTags?: BackupSelectionTagCondition[];
72}
73
74export interface BackupSelection extends Resource<
75 "AWS.Backup.BackupSelection",
76 BackupSelectionProps,
77 {
78 /**
79 * Service-assigned unique ID of the selection.
80 */
81 selectionId: string;
82 /**
83 * Name of the selection.
84 */
85 selectionName: string;
86 /**
87 * ID of the backup plan the selection is attached to.
88 */
89 backupPlanId: string;
90 },
91 never,
92 Providers
93> {}
94
95/**
96 * An AWS Backup selection — assigns AWS resources to a {@link BackupPlan}
97 * either by explicit ARN or by matching resource tags, using an IAM role that
98 * AWS Backup assumes to perform the backups.
99 *
100 * A selection is immutable: any change to its name, role, or resource set
101 * replaces it.
102 *
103 * ### Assigning Resources
104 * **Example:** Assign resources by tag
105 * ```typescript
106 * import * as Backup from "alchemy/AWS/Backup";
107 *
108 * const selection = yield* Backup.BackupSelection("TaggedResources", {
109 * backupPlanId: plan.backupPlanId,
110 * iamRoleArn: backupRole.roleArn,
111 * listOfTags: [
112 * {
113 * conditionType: "STRINGEQUALS",
114 * conditionKey: "aws:ResourceTag/backup",
115 * conditionValue: "daily",
116 * },
117 * ],
118 * });
119 * ```
120 *
121 * **Example:** Assign resources by ARN
122 * ```typescript
123 * const selection = yield* Backup.BackupSelection("ExplicitResources", {
124 * backupPlanId: plan.backupPlanId,
125 * iamRoleArn: backupRole.roleArn,
126 * resources: [table.tableArn],
127 * });
128 * ```
129 *
130 * @resource
131 */
132export const BackupSelection = Resource<BackupSelection>(
133 "AWS.Backup.BackupSelection",
134);
135
136// A freshly-created IAM role is not immediately assumable by AWS Backup, so
137// CreateBackupSelection can transiently reject the role with
138// InvalidParameterValueException. Retry on a bounded schedule. The explicit
139// return annotation keeps `Retry.Return`'s conditional type out of the
140// provider's declaration emit (see SecretsManager/Secret.ts).
141const retryRolePropagation = <A, E extends { _tag: string }, R>(
142 eff: Effect.Effect<A, E, R>,
143): Effect.Effect<A, E, R> =>
144 eff.pipe(
145 Effect.retry({
146 while: (e: E) => e._tag === "InvalidParameterValueException",
147 schedule: Schedule.max([
148 Schedule.fixed("3 seconds"),
149 Schedule.recurs(10),
150 ]),
151 }),
152 );
153
154export const BackupSelectionProvider = () =>
155 Provider.effect(
156 BackupSelection,
157 Effect.gen(function* () {
158 const createName = Effect.fn(function* (
159 id: string,
160 props: { selectionName?: string | undefined },
161 ) {
162 return (
163 props.selectionName ??
164 (yield* createPhysicalName({ id, maxLength: 50 }))
165 );
166 });
167
168 return BackupSelection.Provider.of({
169 stables: ["selectionId", "selectionName", "backupPlanId"],
170 // A selection is keyed by its parent plan; there is no account-wide
171 // enumeration API. Return empty and rely on read/reconcile by id.
172 list: () => Effect.succeed([]),
173 read: Effect.fn(function* ({ output }) {
174 if (!output?.selectionId || !output?.backupPlanId) return undefined;
175 // AWS Backup returns InvalidParameterValueException (not
176 // ResourceNotFoundException) for a selection id that no longer
177 // exists — treat both as "absent".
178 const found = yield* backup
179 .getBackupSelection({
180 BackupPlanId: output.backupPlanId,
181 SelectionId: output.selectionId,
182 })
183 .pipe(
184 Effect.catchTag(
185 ["ResourceNotFoundException", "InvalidParameterValueException"],
186 () => Effect.succeed(undefined),
187 ),
188 );
189 if (!found?.SelectionId) return undefined;
190 return {
191 selectionId: found.SelectionId,
192 selectionName:
193 found.BackupSelection?.SelectionName ?? output.selectionName,
194 backupPlanId: output.backupPlanId,
195 };
196 }),
197 diff: Effect.fn(function* ({ id, news, olds }) {
198 if (!isResolved(news)) return undefined;
199 const oldName = yield* createName(id, olds);
200 const newName = yield* createName(id, news);
201 // The selection API has no update operation, so every meaningful
202 // change is a replacement.
203 if (
204 oldName !== newName ||
205 (olds.backupPlanId ?? "") !== (news.backupPlanId ?? "") ||
206 (olds.iamRoleArn ?? "") !== (news.iamRoleArn ?? "") ||
207 JSON.stringify(olds.resources ?? []) !==
208 JSON.stringify(news.resources ?? []) ||
209 JSON.stringify(olds.notResources ?? []) !==
210 JSON.stringify(news.notResources ?? []) ||
211 JSON.stringify(olds.listOfTags ?? []) !==
212 JSON.stringify(news.listOfTags ?? [])
213 ) {
214 return { action: "replace" } as const;
215 }
216 }),
217 reconcile: Effect.fn(function* ({ id, news, output, session }) {
218 const name = output?.selectionName ?? (yield* createName(id, news));
219 const backupPlanId = news.backupPlanId;
220
221 // OBSERVE — confirm an existing selection by id; a selection is
222 // immutable so there is nothing to sync when it already exists.
223 if (output?.selectionId) {
224 const existing = yield* backup
225 .getBackupSelection({
226 BackupPlanId: backupPlanId,
227 SelectionId: output.selectionId,
228 })
229 .pipe(
230 Effect.catchTag(
231 [
232 "ResourceNotFoundException",
233 "InvalidParameterValueException",
234 ],
235 () => Effect.succeed(undefined),
236 ),
237 );
238 if (existing?.SelectionId) {
239 yield* session.note(existing.SelectionId);
240 return {
241 selectionId: existing.SelectionId,
242 selectionName: name,
243 backupPlanId,
244 };
245 }
246 }
247
248 // ENSURE — create (retrying while the IAM role propagates).
249 const created = yield* retryRolePropagation(
250 backup.createBackupSelection({
251 BackupPlanId: backupPlanId,
252 BackupSelection: {
253 SelectionName: name,
254 IamRoleArn: news.iamRoleArn!,
255 Resources: news.resources,
256 NotResources: news.notResources,
257 ListOfTags: news.listOfTags?.map((c) => ({
258 ConditionType: c.conditionType ?? "STRINGEQUALS",
259 ConditionKey: c.conditionKey,
260 ConditionValue: c.conditionValue,
261 })),
262 },
263 }),
264 );
265 yield* session.note(created.SelectionId!);
266 return {
267 selectionId: created.SelectionId!,
268 selectionName: name,
269 backupPlanId,
270 };
271 }),
272 delete: Effect.fn(function* ({ output }) {
273 // A missing selection deletes as InvalidParameterValueException; a
274 // deleted parent plan yields ResourceNotFoundException. Both are
275 // "already gone".
276 yield* backup
277 .deleteBackupSelection({
278 BackupPlanId: output.backupPlanId,
279 SelectionId: output.selectionId,
280 })
281 .pipe(
282 Effect.catchTag(
283 ["ResourceNotFoundException", "InvalidParameterValueException"],
284 () => Effect.void,
285 ),
286 );
287 }),
288 });
289 }),
290 );