1import * as rbin from "@distilled.cloud/aws/rbin";
2import * as Data from "effect/Data";
3import type * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import {
12 createInternalTags,
13 createTagsList,
14 diffTags,
15 hasAlchemyTags,
16 tagRecord,
17} from "../../Tags.ts";
18import { toWireDays } from "../../Util/Duration.ts";
19import type { Providers } from "../Providers.ts";
20
21/**
22 * A resource tag used to identify (or exclude) resources covered by a
23 * retention rule.
24 */
25export interface RuleResourceTag {
26 /**
27 * The tag key.
28 */
29 key: string;
30 /**
31 * The tag value. Omit to match any resource that has the tag key,
32 * regardless of value.
33 */
34 value?: string;
35}
36
37/**
38 * Lock configuration for a Region-level retention rule. A locked rule
39 * cannot be modified or deleted until it is unlocked and the unlock delay
40 * period expires.
41 */
42export interface RuleLockConfiguration {
43 /**
44 * The unlock delay that must expire after the rule is unlocked before it
45 * can be modified or deleted (e.g. `"7 days"`; valid range 7-30 days).
46 * Sent to the API in whole days.
47 */
48 unlockDelay: Duration.Input;
49}
50
51export interface RuleProps {
52 /**
53 * The resource type to be retained by the retention rule:
54 * `EBS_SNAPSHOT` for Amazon EBS snapshots, `EC2_IMAGE` for EBS-backed
55 * AMIs, or `EBS_VOLUME` for Amazon EBS volumes.
56 * Changing the resource type replaces the rule.
57 */
58 resourceType: "EBS_SNAPSHOT" | "EC2_IMAGE" | "EBS_VOLUME";
59
60 /**
61 * The period for which the retention rule retains resources after they
62 * are deleted (e.g. `"7 days"` or `Duration.days(7)`; valid range
63 * 1-365 days). Sent to the API in whole days.
64 */
65 retentionPeriod: Duration.Input;
66
67 /**
68 * A brief description of the retention rule.
69 */
70 description?: string;
71
72 /**
73 * Resource tags that identify the resources to retain (a **tag-level**
74 * retention rule). Resources of the specified type that have at least one
75 * of these tag key/value pairs are retained in the Recycle Bin upon
76 * deletion. Omit (along with `excludeResourceTags`) to create a
77 * **Region-level** rule that retains all resources of the type in the
78 * Region.
79 */
80 resourceTags?: RuleResourceTag[];
81
82 /**
83 * Exclusion tags for a Region-level retention rule. Resources that have
84 * any of these tag key/value pairs are NOT retained by the rule. Cannot
85 * be combined with `resourceTags` or `lockConfiguration`.
86 */
87 excludeResourceTags?: RuleResourceTag[];
88
89 /**
90 * Lock configuration for the rule. Only Region-level rules without
91 * exclusion tags can be locked. A locked rule cannot be modified or
92 * deleted; removing this prop unlocks the rule, which then remains in
93 * `pending_unlock` until the unlock delay (7-30 days) expires.
94 */
95 lockConfiguration?: RuleLockConfiguration;
96
97 /**
98 * Tags to apply to the retention rule itself. Merged with internal
99 * Alchemy tags.
100 */
101 tags?: Record<string, string>;
102}
103
104export interface Rule extends Resource<
105 "AWS.Rbin.Rule",
106 RuleProps,
107 {
108 /** The unique ID of the retention rule (e.g. `1a2b3c4d-5e6f-...`). */
109 identifier: string;
110 /** The Amazon Resource Name (ARN) of the retention rule. */
111 ruleArn: string;
112 /** The resource type retained by the retention rule. */
113 resourceType: rbin.ResourceType;
114 /** The state of the retention rule (`pending` or `available`). */
115 status: rbin.RuleStatus;
116 /** The lock state of the retention rule, if lockable. */
117 lockState?: rbin.LockState;
118 },
119 never,
120 Providers
121> {}
122
123/**
124 * A Recycle Bin retention rule. Recycle Bin retains deleted EBS snapshots,
125 * EBS-backed AMIs, and EBS volumes for a configurable period so they can be
126 * recovered after accidental deletion.
127 *
128 * Rules are either **tag-level** (retain only resources carrying specific
129 * resource tags) or **Region-level** (retain every resource of the type in
130 * the Region, optionally minus exclusion tags). Changing `resourceType`
131 * replaces the rule; every other property updates in place.
132 *
133 * ### Creating Retention Rules
134 * **Example:** Tag-level rule for EBS snapshots
135 * ```typescript
136 * import * as Rbin from "alchemy/AWS/Rbin";
137 *
138 * const rule = yield* Rbin.Rule("SnapshotRetention", {
139 * resourceType: "EBS_SNAPSHOT",
140 * retentionPeriod: "7 days",
141 * description: "Retain tagged snapshots for 7 days",
142 * resourceTags: [{ key: "team", value: "data" }],
143 * });
144 * ```
145 *
146 * **Example:** Region-level rule for AMIs
147 * ```typescript
148 * const rule = yield* Rbin.Rule("AmiRetention", {
149 * resourceType: "EC2_IMAGE",
150 * retentionPeriod: "14 days",
151 * description: "Retain all deregistered AMIs in this Region",
152 * });
153 * ```
154 *
155 * **Example:** Region-level rule with exclusion tags
156 * ```typescript
157 * const rule = yield* Rbin.Rule("SnapshotRetention", {
158 * resourceType: "EBS_SNAPSHOT",
159 * retentionPeriod: "30 days",
160 * excludeResourceTags: [{ key: "ephemeral", value: "true" }],
161 * });
162 * ```
163 *
164 * ### Locking
165 * A Region-level rule (without exclusion tags) can be locked so it cannot
166 * be modified or deleted. Removing `lockConfiguration` unlocks the rule,
167 * which stays protected in `pending_unlock` until the unlock delay expires.
168 *
169 * **Example:** Locked Region-level rule
170 * ```typescript
171 * const rule = yield* Rbin.Rule("LockedRetention", {
172 * resourceType: "EBS_SNAPSHOT",
173 * retentionPeriod: "30 days",
174 * lockConfiguration: { unlockDelay: "7 days" },
175 * });
176 * ```
177 *
178 * ### Tagging
179 * **Example:** Tag the rule itself
180 * ```typescript
181 * const rule = yield* Rbin.Rule("SnapshotRetention", {
182 * resourceType: "EBS_SNAPSHOT",
183 * retentionPeriod: "7 days",
184 * resourceTags: [{ key: "team", value: "data" }],
185 * tags: { CostCenter: "storage" },
186 * });
187 * ```
188 *
189 * @resource
190 */
191export const Rule = Resource<Rule>("AWS.Rbin.Rule");
192
193/**
194 * Raised when a `Rule` combines `lockConfiguration` with `resourceTags` or
195 * `excludeResourceTags`. Recycle Bin only supports locking Region-level
196 * retention rules that have no exclusion tags.
197 */
198export class RbinLockUnsupported extends Data.TaggedError(
199 "RbinLockUnsupported",
200)<{ message: string }> {}
201
202const validateLock = (props: RuleProps) =>
203 props.lockConfiguration !== undefined &&
204 ((props.resourceTags?.length ?? 0) > 0 ||
205 (props.excludeResourceTags?.length ?? 0) > 0)
206 ? Effect.fail(
207 new RbinLockUnsupported({
208 message:
209 "lockConfiguration is only supported on Region-level retention rules without excludeResourceTags — remove resourceTags/excludeResourceTags or the lock.",
210 }),
211 )
212 : Effect.void;
213
214/** All resource types a retention rule can cover — used to enumerate rules. */
215const RULE_RESOURCE_TYPES = [
216 "EBS_SNAPSHOT",
217 "EC2_IMAGE",
218 "EBS_VOLUME",
219] as const;
220
221const toRetentionPeriod = (p: Duration.Input): rbin.RetentionPeriod => ({
222 // The wire unit is whole days (`DAYS` is the only supported unit).
223 RetentionPeriodValue: toWireDays(p)!,
224 RetentionPeriodUnit: "DAYS",
225});
226
227const toResourceTags = (
228 tags: RuleResourceTag[] | undefined,
229): rbin.ResourceTag[] =>
230 (tags ?? []).map((t) => ({
231 ResourceTagKey: t.key,
232 ...(t.value !== undefined ? { ResourceTagValue: t.value } : {}),
233 }));
234
235const toLockConfiguration = (
236 lock: RuleLockConfiguration,
237): rbin.LockConfiguration => ({
238 UnlockDelay: {
239 // The wire unit is whole days (`DAYS` is the only supported unit).
240 UnlockDelayValue: toWireDays(lock.unlockDelay)!,
241 UnlockDelayUnit: "DAYS",
242 },
243});
244
245/** Order-insensitive fingerprint of a resource-tag set for delta detection. */
246const resourceTagsKey = (tags: readonly rbin.ResourceTag[] | undefined) =>
247 JSON.stringify(
248 (tags ?? [])
249 .map((t) => [t.ResourceTagKey, t.ResourceTagValue ?? ""])
250 .sort((a, b) => (a[0]! < b[0]! ? -1 : a[0]! > b[0]! ? 1 : 0)),
251 );
252
253const toAttributes = (live: rbin.GetRuleResponse): Rule["Attributes"] => ({
254 identifier: live.Identifier!,
255 ruleArn: live.RuleArn!,
256 resourceType: live.ResourceType!,
257 status: live.Status ?? "available",
258 lockState: live.LockState,
259});
260
261/** Read a rule; a typed RULE_NOT_FOUND becomes `undefined`. */
262const readRule = (identifier: string) =>
263 rbin
264 .getRule({ Identifier: identifier })
265 .pipe(
266 Effect.catchTag("ResourceNotFoundException", () =>
267 Effect.succeed(undefined),
268 ),
269 );
270
271/** Observed tags on the rule itself (empty on a NotFound race). */
272const readRuleTags = (ruleArn: string) =>
273 rbin.listTagsForResource({ ResourceArn: ruleArn }).pipe(
274 Effect.map((r) => tagRecord(r.Tags ?? [])),
275 Effect.catchTag("ResourceNotFoundException", () =>
276 Effect.succeed({} as Record<string, string>),
277 ),
278 );
279
280/** Collect all rule summaries for one resource type (exhaustive pages). */
281const listRuleSummaries = (resourceType: rbin.ResourceType) =>
282 rbin.listRules.items({ ResourceType: resourceType }).pipe(
283 Stream.runCollect,
284 Effect.map((chunk) => Array.from(chunk)),
285 // Tolerate a resource type the Region/partition doesn't support yet.
286 Effect.catchTag("ValidationException", () =>
287 Effect.succeed([] as rbin.RuleSummary[]),
288 ),
289 );
290
291class RuleStillPending extends Data.TaggedError("RuleStillPending")<{
292 identifier: string;
293}> {}
294
295class RuleStillExists extends Data.TaggedError("RuleStillExists")<{
296 identifier: string;
297}> {}
298
299/**
300 * Bounded retry while a freshly created/updated rule reports `pending`.
301 * Extracted to module scope with an explicit signature so the conditional
302 * `Retry.Return` type never leaks into the provider layer's declaration.
303 */
304const retryWhileRulePending = <A, E extends { _tag: string }, R>(
305 self: Effect.Effect<A, E | RuleStillPending, R>,
306): Effect.Effect<A, E | RuleStillPending, R> =>
307 Effect.retry(self, {
308 while: (e) => e._tag === "RuleStillPending",
309 schedule: Schedule.max([Schedule.fixed("1 second"), Schedule.recurs(30)]),
310 });
311
312/** Poll a rule until it leaves the transient `pending` status. */
313const waitForRuleAvailable = (identifier: string) =>
314 retryWhileRulePending(
315 Effect.gen(function* () {
316 const live = yield* rbin.getRule({ Identifier: identifier });
317 if (live.Status === "pending") {
318 return yield* new RuleStillPending({ identifier });
319 }
320 return live;
321 }),
322 );
323
324/** Bounded retry while the rule is still observable after deletion. */
325const retryWhileRuleExists = <A, E extends { _tag: string }, R>(
326 self: Effect.Effect<A, E | RuleStillExists, R>,
327): Effect.Effect<A, E | RuleStillExists, R> =>
328 Effect.retry(self, {
329 while: (e) => e._tag === "RuleStillExists",
330 schedule: Schedule.max([Schedule.fixed("1 second"), Schedule.recurs(15)]),
331 });
332
333export const RuleProvider = () =>
334 Provider.effect(
335 Rule,
336 Effect.gen(function* () {
337 return Rule.Provider.of({
338 stables: ["identifier", "ruleArn", "resourceType"],
339
340 // Enumerate every retention rule in the ambient account/region across
341 // all supported resource types, hydrating each summary to the full
342 // Attributes shape. A rule can vanish between enumeration and
343 // hydration, so tolerate the typed NotFound per item and drop it.
344 list: () =>
345 Effect.gen(function* () {
346 const summaries = yield* Effect.forEach(
347 RULE_RESOURCE_TYPES,
348 (resourceType) => listRuleSummaries(resourceType),
349 { concurrency: 3 },
350 ).pipe(Effect.map((groups) => groups.flat()));
351 const items = yield* Effect.forEach(
352 summaries.filter((s) => s.Identifier !== undefined),
353 (summary) =>
354 readRule(summary.Identifier!).pipe(
355 Effect.map((live) =>
356 live === undefined ? undefined : toAttributes(live),
357 ),
358 ),
359 { concurrency: 5 },
360 );
361 return items.filter(
362 (item): item is Rule["Attributes"] => item !== undefined,
363 );
364 }),
365
366 // Rule identifiers are server-generated, so without a cached output
367 // we search for a rule branded with our alchemy ownership tags.
368 read: Effect.fn(function* ({ id, olds, output }) {
369 if (output?.identifier) {
370 const live = yield* readRule(output.identifier);
371 if (!live) return undefined;
372 const attrs = toAttributes(live);
373 const tags = yield* readRuleTags(attrs.ruleArn);
374 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
375 }
376 const resourceTypes = olds?.resourceType
377 ? [olds.resourceType]
378 : RULE_RESOURCE_TYPES;
379 for (const resourceType of resourceTypes) {
380 const summaries = yield* listRuleSummaries(resourceType);
381 for (const summary of summaries) {
382 if (!summary.Identifier || !summary.RuleArn) continue;
383 const tags = yield* readRuleTags(summary.RuleArn);
384 if (yield* hasAlchemyTags(id, tags)) {
385 const live = yield* readRule(summary.Identifier);
386 if (live) return toAttributes(live);
387 }
388 }
389 }
390 return undefined;
391 }),
392
393 diff: Effect.fn(function* ({ news, olds }) {
394 if (!isResolved(news)) return undefined;
395 yield* validateLock(news);
396 // The resource type of a retention rule cannot be changed after
397 // creation — a change requires replacement.
398 if (news.resourceType !== olds.resourceType) {
399 return { action: "replace" } as const;
400 }
401 // Everything else (retention period, description, resource tags,
402 // exclusion tags, lock, tags) updates in place.
403 }),
404
405 reconcile: Effect.fn(function* ({ id, news, output, session }) {
406 yield* validateLock(news);
407 const internalTags = yield* createInternalTags(id);
408 const desiredTags = { ...news.tags, ...internalTags };
409
410 // OBSERVE — `output.identifier` is only a cache; a rule deleted
411 // out-of-band falls through to create.
412 let live = output?.identifier
413 ? yield* readRule(output.identifier)
414 : undefined;
415
416 // ENSURE — create if missing, then wait out the transient
417 // `pending` status so downstream sync reads settled state.
418 if (live === undefined) {
419 const created = yield* rbin.createRule({
420 ResourceType: news.resourceType,
421 RetentionPeriod: toRetentionPeriod(news.retentionPeriod),
422 Description: news.description,
423 ...(news.resourceTags
424 ? { ResourceTags: toResourceTags(news.resourceTags) }
425 : {}),
426 ...(news.excludeResourceTags
427 ? {
428 ExcludeResourceTags: toResourceTags(
429 news.excludeResourceTags,
430 ),
431 }
432 : {}),
433 ...(news.lockConfiguration
434 ? {
435 LockConfiguration: toLockConfiguration(
436 news.lockConfiguration,
437 ),
438 }
439 : {}),
440 Tags: createTagsList(desiredTags),
441 });
442 yield* session.note(`Created retention rule ${created.Identifier}`);
443 live = yield* waitForRuleAvailable(created.Identifier!);
444 }
445 const identifier = live.Identifier!;
446 const ruleArn = live.RuleArn!;
447
448 // SYNC mutable settings — diff OBSERVED cloud state against the
449 // desired props and apply only the delta. A no-op skips the API
450 // call entirely (a fresh create always lands here as a no-op).
451 const desiredRetention = toRetentionPeriod(news.retentionPeriod);
452 const desiredResourceTags = toResourceTags(news.resourceTags);
453 const desiredExcludeTags = toResourceTags(news.excludeResourceTags);
454 const retentionChanged =
455 live.RetentionPeriod?.RetentionPeriodValue !==
456 desiredRetention.RetentionPeriodValue ||
457 live.RetentionPeriod?.RetentionPeriodUnit !==
458 desiredRetention.RetentionPeriodUnit;
459 const descriptionChanged =
460 (live.Description ?? "") !== (news.description ?? "");
461 const resourceTagsChanged =
462 resourceTagsKey(live.ResourceTags) !==
463 resourceTagsKey(desiredResourceTags);
464 const excludeTagsChanged =
465 resourceTagsKey(live.ExcludeResourceTags) !==
466 resourceTagsKey(desiredExcludeTags);
467 if (
468 retentionChanged ||
469 descriptionChanged ||
470 resourceTagsChanged ||
471 excludeTagsChanged
472 ) {
473 yield* rbin.updateRule({
474 Identifier: identifier,
475 ...(retentionChanged
476 ? { RetentionPeriod: desiredRetention }
477 : {}),
478 ...(descriptionChanged
479 ? { Description: news.description ?? "" }
480 : {}),
481 ...(resourceTagsChanged
482 ? { ResourceTags: desiredResourceTags }
483 : {}),
484 ...(excludeTagsChanged
485 ? { ExcludeResourceTags: desiredExcludeTags }
486 : {}),
487 });
488 live = yield* waitForRuleAvailable(identifier);
489 }
490
491 // SYNC lock state — observed lock state vs desired. Unlocking
492 // starts the unlock delay (`pending_unlock`); re-locking during
493 // that window is allowed and converges back to `locked`.
494 if (news.lockConfiguration !== undefined) {
495 const desiredLock = toLockConfiguration(news.lockConfiguration);
496 const lockDelta =
497 live.LockState !== "locked" ||
498 live.LockConfiguration?.UnlockDelay.UnlockDelayValue !==
499 desiredLock.UnlockDelay.UnlockDelayValue ||
500 live.LockConfiguration?.UnlockDelay.UnlockDelayUnit !==
501 desiredLock.UnlockDelay.UnlockDelayUnit;
502 if (lockDelta) {
503 yield* rbin.lockRule({
504 Identifier: identifier,
505 LockConfiguration: desiredLock,
506 });
507 yield* session.note(`Locked retention rule ${identifier}`);
508 }
509 } else if (live.LockState === "locked") {
510 yield* rbin.unlockRule({ Identifier: identifier });
511 yield* session.note(
512 `Unlock initiated for ${identifier}; rule stays protected (pending_unlock) until the unlock delay expires`,
513 );
514 }
515
516 // SYNC tags — diff against OBSERVED cloud tags (never olds/output)
517 // so adoption converges.
518 const currentTags = yield* readRuleTags(ruleArn);
519 const { upsert, removed } = diffTags(currentTags, desiredTags);
520 if (upsert.length > 0) {
521 yield* rbin.tagResource({ ResourceArn: ruleArn, Tags: upsert });
522 }
523 if (removed.length > 0) {
524 yield* rbin.untagResource({
525 ResourceArn: ruleArn,
526 TagKeys: removed,
527 });
528 }
529
530 // RETURN fresh attributes reflecting all sync steps.
531 const final = yield* rbin.getRule({ Identifier: identifier });
532 yield* session.note(identifier);
533 return toAttributes(final);
534 }),
535
536 // Idempotent: a rule already gone is success. A locked rule fails
537 // with the typed ConflictException (INVALID_RULE_STATE) — it must be
538 // unlocked and its unlock delay must expire before deletion.
539 delete: Effect.fn(function* ({ output }) {
540 yield* rbin
541 .deleteRule({ Identifier: output.identifier })
542 .pipe(
543 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
544 );
545 // Deletion is near-immediate, but confirm the rule is gone so
546 // dependents (and re-creates of the same logical id) never observe
547 // a half-deleted rule.
548 yield* retryWhileRuleExists(
549 Effect.gen(function* () {
550 const live = yield* readRule(output.identifier);
551 if (live !== undefined) {
552 return yield* new RuleStillExists({
553 identifier: output.identifier,
554 });
555 }
556 }),
557 );
558 }),
559 });
560 }),
561 );