| 1 | import * as rbin from "@distilled.cloud/aws/rbin"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import type * as Duration from "effect/Duration"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Schedule from "effect/Schedule"; |
| 6 | import * as Stream from "effect/Stream"; |
| 7 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 8 | import { isResolved } from "../../Diff.ts"; |
| 9 | import * as Provider from "../../Provider.ts"; |
| 10 | import { Resource } from "../../Resource.ts"; |
| 11 | import { |
| 12 | createInternalTags, |
| 13 | createTagsList, |
| 14 | diffTags, |
| 15 | hasAlchemyTags, |
| 16 | tagRecord, |
| 17 | } from "../../Tags.ts"; |
| 18 | import { toWireDays } from "../../Util/Duration.ts"; |
| 19 | import type { Providers } from "../Providers.ts"; |
| 20 | |
| 21 | /** |
| 22 | * A resource tag used to identify (or exclude) resources covered by a |
| 23 | * retention rule. |
| 24 | */ |
| 25 | export 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 | */ |
| 42 | export 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 | |
| 51 | export 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 | |
| 104 | export 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 | */ |
| 191 | export 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 | */ |
| 198 | export class RbinLockUnsupported extends Data.TaggedError( |
| 199 | "RbinLockUnsupported", |
| 200 | )<{ message: string }> {} |
| 201 | |
| 202 | const 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. */ |
| 215 | const RULE_RESOURCE_TYPES = [ |
| 216 | "EBS_SNAPSHOT", |
| 217 | "EC2_IMAGE", |
| 218 | "EBS_VOLUME", |
| 219 | ] as const; |
| 220 | |
| 221 | const 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 | |
| 227 | const 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 | |
| 235 | const 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. */ |
| 246 | const 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 | |
| 253 | const 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`. */ |
| 262 | const 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). */ |
| 272 | const 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). */ |
| 281 | const 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 | |
| 291 | class RuleStillPending extends Data.TaggedError("RuleStillPending")<{ |
| 292 | identifier: string; |
| 293 | }> {} |
| 294 | |
| 295 | class 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 | */ |
| 304 | const 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. */ |
| 313 | const 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. */ |
| 325 | const 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 | |
| 333 | export 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 | ); |