| 1 | import * as ec2 from "@distilled.cloud/aws/ec2"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | |
| 7 | import type { ScopedPlanStatusSession } from "../../Report.ts"; |
| 8 | import { isResolved, somePropsAreDifferent } from "../../Diff.ts"; |
| 9 | import * as Provider from "../../Provider.ts"; |
| 10 | import { Resource } from "../../Resource.ts"; |
| 11 | import { |
| 12 | createAlchemyTagFilters, |
| 13 | createInternalTags, |
| 14 | createTagsList, |
| 15 | diffTags, |
| 16 | } from "../../Tags.ts"; |
| 17 | import type { AccountID } from "../Environment.ts"; |
| 18 | import { AWSEnvironment } from "../Environment.ts"; |
| 19 | import type { RegionID } from "../Region.ts"; |
| 20 | import type { Providers } from "../Providers.ts"; |
| 21 | |
| 22 | export type VolumeId<ID extends string = string> = `vol-${ID}`; |
| 23 | export const VolumeId = <ID extends string>(id: ID): ID & VolumeId<ID> => |
| 24 | `vol-${id}` as ID & VolumeId<ID>; |
| 25 | |
| 26 | export type VolumeArn = |
| 27 | `arn:aws:ec2:${RegionID}:${AccountID}:volume/${VolumeId}`; |
| 28 | |
| 29 | export interface VolumeProps { |
| 30 | /** |
| 31 | * The Availability Zone in which to create the volume, e.g. `us-east-1a`. |
| 32 | * Required. An EBS volume is bound to a single AZ; changing it replaces the |
| 33 | * volume. |
| 34 | */ |
| 35 | availabilityZone: string; |
| 36 | |
| 37 | /** |
| 38 | * The size of the volume, in GiB. Required unless you create the volume from |
| 39 | * a `snapshotId` (in which case it defaults to the snapshot size). The size |
| 40 | * can be increased in place via `modifyVolume`; decreasing it replaces the |
| 41 | * volume. |
| 42 | */ |
| 43 | size?: number; |
| 44 | |
| 45 | /** |
| 46 | * The volume type. |
| 47 | * @default "gp3" |
| 48 | */ |
| 49 | volumeType?: ec2.VolumeType; |
| 50 | |
| 51 | /** |
| 52 | * The number of I/O operations per second (IOPS). Only valid for `io1`, |
| 53 | * `io2`, and `gp3` volumes. Mutable in place via `modifyVolume`. |
| 54 | */ |
| 55 | iops?: number; |
| 56 | |
| 57 | /** |
| 58 | * The throughput to provision, in MiB/s. Only valid for `gp3` volumes. |
| 59 | * Mutable in place via `modifyVolume`. |
| 60 | */ |
| 61 | throughput?: number; |
| 62 | |
| 63 | /** |
| 64 | * Whether the volume is encrypted. Changing this replaces the volume. |
| 65 | * @default false |
| 66 | */ |
| 67 | encrypted?: boolean; |
| 68 | |
| 69 | /** |
| 70 | * The identifier of the KMS key to use for encryption. Can be a key ID, key |
| 71 | * ARN, alias name (`alias/my-key`), or alias ARN. Implies `encrypted: true`. |
| 72 | * Changing this replaces the volume. |
| 73 | */ |
| 74 | kmsKeyId?: string; |
| 75 | |
| 76 | /** |
| 77 | * The snapshot from which to create the volume. Changing this replaces the |
| 78 | * volume. |
| 79 | */ |
| 80 | snapshotId?: string; |
| 81 | |
| 82 | /** |
| 83 | * Whether the volume can be attached to multiple instances (Multi-Attach). |
| 84 | * Only valid for `io1`/`io2` volumes. |
| 85 | * @default false |
| 86 | */ |
| 87 | multiAttachEnabled?: boolean; |
| 88 | |
| 89 | /** |
| 90 | * Tags to assign to the volume. Merged with alchemy auto-tags |
| 91 | * (alchemy::stack, alchemy::stage, alchemy::id). |
| 92 | */ |
| 93 | tags?: Record<string, string>; |
| 94 | } |
| 95 | |
| 96 | export interface Volume extends Resource< |
| 97 | "AWS.EC2.Volume", |
| 98 | VolumeProps, |
| 99 | { |
| 100 | /** |
| 101 | * The ID of the volume. |
| 102 | */ |
| 103 | volumeId: VolumeId; |
| 104 | |
| 105 | /** |
| 106 | * The Amazon Resource Name (ARN) of the volume. |
| 107 | */ |
| 108 | volumeArn: VolumeArn; |
| 109 | |
| 110 | /** |
| 111 | * The Availability Zone of the volume. |
| 112 | */ |
| 113 | availabilityZone: string; |
| 114 | |
| 115 | /** |
| 116 | * The size of the volume, in GiB. |
| 117 | */ |
| 118 | size: number; |
| 119 | |
| 120 | /** |
| 121 | * The volume type. |
| 122 | */ |
| 123 | volumeType: ec2.VolumeType; |
| 124 | |
| 125 | /** |
| 126 | * The number of IOPS provisioned for the volume. |
| 127 | */ |
| 128 | iops?: number; |
| 129 | |
| 130 | /** |
| 131 | * The throughput provisioned for the volume, in MiB/s. |
| 132 | */ |
| 133 | throughput?: number; |
| 134 | |
| 135 | /** |
| 136 | * Whether the volume is encrypted. |
| 137 | */ |
| 138 | encrypted: boolean; |
| 139 | |
| 140 | /** |
| 141 | * The KMS key used for encryption, if any. |
| 142 | */ |
| 143 | kmsKeyId?: string; |
| 144 | |
| 145 | /** |
| 146 | * The snapshot the volume was created from, if any. |
| 147 | */ |
| 148 | snapshotId?: string; |
| 149 | |
| 150 | /** |
| 151 | * Whether Multi-Attach is enabled. |
| 152 | */ |
| 153 | multiAttachEnabled: boolean; |
| 154 | |
| 155 | /** |
| 156 | * The current state of the volume. |
| 157 | */ |
| 158 | state: ec2.VolumeState; |
| 159 | }, |
| 160 | never, |
| 161 | Providers |
| 162 | > {} |
| 163 | /** |
| 164 | * An Elastic Block Store (EBS) volume — durable block storage you attach to an |
| 165 | * EC2 instance via a {@link VolumeAttachment}. Volumes live in a single |
| 166 | * Availability Zone and persist independently of any instance. |
| 167 | * |
| 168 | * Changing `availabilityZone`, `encrypted`, `kmsKeyId`, `snapshotId`, or |
| 169 | * shrinking `size` replaces the volume. Growing `size` and changing `iops`, |
| 170 | * `throughput`, or `volumeType` are applied in place via `modifyVolume` (note |
| 171 | * AWS enforces a 6-hour cooldown between volume modifications). |
| 172 | * |
| 173 | * ### Creating a Volume |
| 174 | * **Example:** Basic gp3 Volume |
| 175 | * ```typescript |
| 176 | * const volume = yield* AWS.EC2.Volume("DataVolume", { |
| 177 | * availabilityZone: "us-east-1a", |
| 178 | * size: 20, |
| 179 | * volumeType: "gp3", |
| 180 | * }); |
| 181 | * ``` |
| 182 | * |
| 183 | * The minimal volume: a 20 GiB general-purpose `gp3` volume in one AZ. It must |
| 184 | * be in the same AZ as the instance you attach it to. |
| 185 | * |
| 186 | * ### Provisioned Performance |
| 187 | * **Example:** gp3 with Provisioned IOPS and Throughput |
| 188 | * ```typescript |
| 189 | * const fast = yield* AWS.EC2.Volume("FastVolume", { |
| 190 | * availabilityZone: "us-east-1a", |
| 191 | * size: 100, |
| 192 | * volumeType: "gp3", |
| 193 | * iops: 6000, |
| 194 | * throughput: 250, |
| 195 | * }); |
| 196 | * ``` |
| 197 | * |
| 198 | * `gp3` decouples IOPS and throughput from size, so you can provision up to |
| 199 | * 16,000 IOPS and 1,000 MiB/s independently. Use `io2` for the highest |
| 200 | * durability and IOPS ceilings. |
| 201 | * |
| 202 | * ### Encryption |
| 203 | * **Example:** Encrypted Volume with a KMS Key |
| 204 | * ```typescript |
| 205 | * const secure = yield* AWS.EC2.Volume("SecureVolume", { |
| 206 | * availabilityZone: "us-east-1a", |
| 207 | * size: 20, |
| 208 | * encrypted: true, |
| 209 | * kmsKeyId: "alias/my-app-key", |
| 210 | * }); |
| 211 | * ``` |
| 212 | * |
| 213 | * Setting `kmsKeyId` implies encryption. Omit it while setting `encrypted: |
| 214 | * true` to use the account's default EBS KMS key. |
| 215 | * |
| 216 | * ### Creating from a Snapshot |
| 217 | * **Example:** Restore a Volume from a Snapshot |
| 218 | * ```typescript |
| 219 | * const restored = yield* AWS.EC2.Volume("RestoredVolume", { |
| 220 | * availabilityZone: "us-east-1a", |
| 221 | * snapshotId: snapshot.snapshotId, |
| 222 | * }); |
| 223 | * ``` |
| 224 | * |
| 225 | * When you create a volume from a snapshot, `size` defaults to the snapshot's |
| 226 | * size and can only be grown, never shrunk. |
| 227 | * |
| 228 | * @resource |
| 229 | */ |
| 230 | export const Volume = Resource<Volume>("AWS.EC2.Volume"); |
| 231 | |
| 232 | export const VolumeProvider = () => |
| 233 | Provider.effect( |
| 234 | Volume, |
| 235 | Effect.gen(function* () { |
| 236 | // EBS volume IDs are server-assigned. Recover a successfully created |
| 237 | // volume after state persistence failure via the resource instance tag; |
| 238 | // logical-id-only tags are insufficient during replacement because the |
| 239 | // old and new volume instances can coexist. |
| 240 | const findVolumeByInstanceTags = Effect.fn(function* ( |
| 241 | id: string, |
| 242 | instanceId: string, |
| 243 | ) { |
| 244 | const filters = yield* createAlchemyTagFilters(id); |
| 245 | const pages = yield* ec2.describeVolumes |
| 246 | .pages({ |
| 247 | Filters: [ |
| 248 | ...filters, |
| 249 | { Name: "tag:alchemy::instance", Values: [instanceId] }, |
| 250 | ], |
| 251 | }) |
| 252 | .pipe(Stream.runCollect); |
| 253 | return Array.from(pages) |
| 254 | .flatMap((page) => page.Volumes ?? []) |
| 255 | .find( |
| 256 | (volume) => |
| 257 | volume.State !== "deleting" && volume.State !== "deleted", |
| 258 | ); |
| 259 | }); |
| 260 | |
| 261 | return { |
| 262 | stables: [ |
| 263 | "volumeId", |
| 264 | "volumeArn", |
| 265 | "availabilityZone", |
| 266 | "encrypted", |
| 267 | "kmsKeyId", |
| 268 | "snapshotId", |
| 269 | ], |
| 270 | |
| 271 | diff: Effect.fn(function* ({ news, olds }) { |
| 272 | if (!isResolved(news)) return; |
| 273 | if ( |
| 274 | somePropsAreDifferent(olds, news, [ |
| 275 | "availabilityZone", |
| 276 | "encrypted", |
| 277 | "kmsKeyId", |
| 278 | "snapshotId", |
| 279 | ]) |
| 280 | ) { |
| 281 | return { action: "replace" }; |
| 282 | } |
| 283 | // Shrinking a volume is not supported in place — replace. |
| 284 | if ( |
| 285 | olds.size !== undefined && |
| 286 | news.size !== undefined && |
| 287 | news.size < olds.size |
| 288 | ) { |
| 289 | return { action: "replace" }; |
| 290 | } |
| 291 | }), |
| 292 | |
| 293 | reconcile: Effect.fn(function* ({ |
| 294 | id, |
| 295 | instanceId, |
| 296 | news, |
| 297 | output, |
| 298 | session, |
| 299 | }) { |
| 300 | const { accountId, region } = yield* AWSEnvironment.current; |
| 301 | const alchemyTags = yield* createInternalTags(id); |
| 302 | const desiredTags = { |
| 303 | ...news.tags, |
| 304 | ...alchemyTags, |
| 305 | "alchemy::instance": instanceId, |
| 306 | }; |
| 307 | |
| 308 | // 1. OBSERVE — cloud state is authoritative; output is only an id |
| 309 | // cache. A volume in a terminal state is treated as missing. |
| 310 | let volume: ec2.Volume | undefined; |
| 311 | if (output?.volumeId) { |
| 312 | const lookup = yield* ec2 |
| 313 | .describeVolumes({ VolumeIds: [output.volumeId] }) |
| 314 | .pipe( |
| 315 | Effect.catchTag("InvalidVolume.NotFound", () => |
| 316 | Effect.succeed({ Volumes: [] }), |
| 317 | ), |
| 318 | ); |
| 319 | const found = lookup.Volumes?.[0]; |
| 320 | if ( |
| 321 | found && |
| 322 | found.State !== "deleting" && |
| 323 | found.State !== "deleted" |
| 324 | ) { |
| 325 | volume = found; |
| 326 | } |
| 327 | } |
| 328 | if (volume === undefined) { |
| 329 | volume = yield* findVolumeByInstanceTags(id, instanceId); |
| 330 | } |
| 331 | |
| 332 | // 2. ENSURE — create the volume when missing, then wait until it is |
| 333 | // available. |
| 334 | if (volume === undefined) { |
| 335 | const created = yield* ec2.createVolume({ |
| 336 | AvailabilityZone: news.availabilityZone, |
| 337 | Size: news.size, |
| 338 | VolumeType: news.volumeType ?? "gp3", |
| 339 | Iops: news.iops, |
| 340 | Throughput: news.throughput, |
| 341 | Encrypted: |
| 342 | news.encrypted ?? |
| 343 | (news.kmsKeyId !== undefined ? true : undefined), |
| 344 | KmsKeyId: news.kmsKeyId, |
| 345 | SnapshotId: news.snapshotId, |
| 346 | MultiAttachEnabled: news.multiAttachEnabled, |
| 347 | TagSpecifications: [ |
| 348 | { |
| 349 | ResourceType: "volume", |
| 350 | Tags: createTagsList(desiredTags), |
| 351 | }, |
| 352 | ], |
| 353 | DryRun: false, |
| 354 | }); |
| 355 | const volumeId = created.VolumeId! as VolumeId; |
| 356 | yield* session.note(`Volume created: ${volumeId}`); |
| 357 | volume = yield* waitForVolumeAvailable(volumeId, session); |
| 358 | } |
| 359 | |
| 360 | const volumeId = volume.VolumeId! as VolumeId; |
| 361 | |
| 362 | // 3. SYNC — grow size / adjust performance in place. Only include a |
| 363 | // field when the user specified it AND it drifts from observed |
| 364 | // cloud state, so a no-op reconcile never triggers the 6h |
| 365 | // modification cooldown. |
| 366 | const modify: ec2.ModifyVolumeRequest = { VolumeId: volumeId }; |
| 367 | let needsModify = false; |
| 368 | if (news.size !== undefined && news.size > (volume.Size ?? 0)) { |
| 369 | modify.Size = news.size; |
| 370 | needsModify = true; |
| 371 | } |
| 372 | if ( |
| 373 | news.volumeType !== undefined && |
| 374 | news.volumeType !== volume.VolumeType |
| 375 | ) { |
| 376 | modify.VolumeType = news.volumeType; |
| 377 | needsModify = true; |
| 378 | } |
| 379 | if (news.iops !== undefined && news.iops !== volume.Iops) { |
| 380 | modify.Iops = news.iops; |
| 381 | needsModify = true; |
| 382 | } |
| 383 | if ( |
| 384 | news.throughput !== undefined && |
| 385 | news.throughput !== volume.Throughput |
| 386 | ) { |
| 387 | modify.Throughput = news.throughput; |
| 388 | needsModify = true; |
| 389 | } |
| 390 | if (needsModify) { |
| 391 | yield* ec2.modifyVolume(modify); |
| 392 | yield* session.note(`Volume modified: ${volumeId}`); |
| 393 | } |
| 394 | |
| 395 | // 3b. SYNC TAGS — diff against observed cloud tags. |
| 396 | const currentTags = Object.fromEntries( |
| 397 | (volume.Tags ?? []).map((t) => [t.Key!, t.Value!]), |
| 398 | ) as Record<string, string>; |
| 399 | const { removed, upsert } = diffTags(currentTags, desiredTags); |
| 400 | if (removed.length > 0) { |
| 401 | yield* ec2.deleteTags({ |
| 402 | Resources: [volumeId], |
| 403 | Tags: removed.map((key) => ({ Key: key })), |
| 404 | DryRun: false, |
| 405 | }); |
| 406 | } |
| 407 | if (upsert.length > 0) { |
| 408 | yield* ec2.createTags({ |
| 409 | Resources: [volumeId], |
| 410 | Tags: upsert, |
| 411 | DryRun: false, |
| 412 | }); |
| 413 | } |
| 414 | |
| 415 | // 4. RETURN fresh attributes. |
| 416 | const finalLookup = yield* ec2.describeVolumes({ |
| 417 | VolumeIds: [volumeId], |
| 418 | }); |
| 419 | const final = finalLookup.Volumes?.[0] ?? volume; |
| 420 | return toVolumeAttributes(final, region, accountId); |
| 421 | }), |
| 422 | |
| 423 | // Enumerate every volume in the ambient account/region. |
| 424 | list: () => |
| 425 | Effect.gen(function* () { |
| 426 | const { accountId, region } = yield* AWSEnvironment.current; |
| 427 | const chunk = yield* ec2.describeVolumes |
| 428 | .pages({}) |
| 429 | .pipe(Stream.runCollect); |
| 430 | return Array.from(chunk).flatMap((page) => |
| 431 | (page.Volumes ?? []).map((v) => |
| 432 | toVolumeAttributes(v, region, accountId), |
| 433 | ), |
| 434 | ); |
| 435 | }), |
| 436 | |
| 437 | delete: Effect.fn(function* ({ output, session }) { |
| 438 | const volumeId = output.volumeId; |
| 439 | yield* session.note(`Deleting volume: ${volumeId}`); |
| 440 | |
| 441 | yield* ec2.deleteVolume({ VolumeId: volumeId, DryRun: false }).pipe( |
| 442 | Effect.tapError(Effect.logDebug), |
| 443 | Effect.catchTag("InvalidVolume.NotFound", () => Effect.void), |
| 444 | // A volume that is still detaching reports VolumeInUse — retry |
| 445 | // until the detach completes. |
| 446 | Effect.retry({ |
| 447 | while: (e) => e._tag === "VolumeInUse", |
| 448 | schedule: Schedule.max([ |
| 449 | Schedule.fixed(3000), |
| 450 | Schedule.recurs(20), |
| 451 | ]).pipe( |
| 452 | Schedule.tap(({ attempt }) => |
| 453 | session.note( |
| 454 | `Waiting for volume to detach... (attempt ${attempt + 1})`, |
| 455 | ), |
| 456 | ), |
| 457 | ), |
| 458 | }), |
| 459 | ); |
| 460 | |
| 461 | yield* waitForVolumeDeleted(volumeId, session); |
| 462 | yield* session.note(`Volume ${volumeId} deleted successfully`); |
| 463 | }), |
| 464 | }; |
| 465 | }), |
| 466 | ); |
| 467 | |
| 468 | const toVolumeAttributes = ( |
| 469 | volume: ec2.Volume, |
| 470 | region: RegionID, |
| 471 | accountId: AccountID, |
| 472 | ): Volume["Attributes"] => { |
| 473 | const volumeId = volume.VolumeId! as VolumeId; |
| 474 | return { |
| 475 | volumeId, |
| 476 | volumeArn: |
| 477 | `arn:aws:ec2:${region}:${accountId}:volume/${volumeId}` as VolumeArn, |
| 478 | availabilityZone: volume.AvailabilityZone!, |
| 479 | size: volume.Size ?? 0, |
| 480 | volumeType: volume.VolumeType ?? "gp3", |
| 481 | iops: volume.Iops, |
| 482 | throughput: volume.Throughput, |
| 483 | encrypted: volume.Encrypted ?? false, |
| 484 | kmsKeyId: volume.KmsKeyId, |
| 485 | snapshotId: |
| 486 | volume.SnapshotId && volume.SnapshotId.length > 0 |
| 487 | ? volume.SnapshotId |
| 488 | : undefined, |
| 489 | multiAttachEnabled: volume.MultiAttachEnabled ?? false, |
| 490 | state: volume.State ?? "creating", |
| 491 | }; |
| 492 | }; |
| 493 | |
| 494 | class VolumeNotReady extends Data.TaggedError("VolumeNotReady")<{ |
| 495 | volumeId: string; |
| 496 | state: string; |
| 497 | }> {} |
| 498 | |
| 499 | class VolumeStillExists extends Data.TaggedError("VolumeStillExists")<{ |
| 500 | volumeId: string; |
| 501 | state: string; |
| 502 | message: string; |
| 503 | }> {} |
| 504 | |
| 505 | /** |
| 506 | * Wait for the volume to reach the `available` state. |
| 507 | */ |
| 508 | const waitForVolumeAvailable = ( |
| 509 | volumeId: string, |
| 510 | session?: ScopedPlanStatusSession, |
| 511 | ) => |
| 512 | Effect.gen(function* () { |
| 513 | const result = yield* ec2.describeVolumes({ VolumeIds: [volumeId] }); |
| 514 | const volume = result.Volumes?.[0]; |
| 515 | if (!volume) { |
| 516 | return yield* Effect.fail(new Error(`Volume ${volumeId} not found`)); |
| 517 | } |
| 518 | if (volume.State === "available" || volume.State === "in-use") { |
| 519 | return volume; |
| 520 | } |
| 521 | if (volume.State === "error") { |
| 522 | return yield* Effect.fail( |
| 523 | new Error(`Volume ${volumeId} entered error state`), |
| 524 | ); |
| 525 | } |
| 526 | return yield* new VolumeNotReady({ volumeId, state: volume.State! }); |
| 527 | }).pipe( |
| 528 | Effect.retry({ |
| 529 | while: (e) => e instanceof VolumeNotReady, |
| 530 | schedule: Schedule.max([ |
| 531 | Schedule.fixed(2000), |
| 532 | Schedule.recurs(30), // max ~60s |
| 533 | ]).pipe( |
| 534 | Schedule.tap(({ attempt }) => |
| 535 | session |
| 536 | ? session.note( |
| 537 | `Waiting for volume to be available... (${(attempt + 1) * 2}s)`, |
| 538 | ) |
| 539 | : Effect.void, |
| 540 | ), |
| 541 | ), |
| 542 | }), |
| 543 | ); |
| 544 | |
| 545 | /** |
| 546 | * Wait for the volume to be fully deleted. |
| 547 | */ |
| 548 | const waitForVolumeDeleted = ( |
| 549 | volumeId: string, |
| 550 | session: ScopedPlanStatusSession, |
| 551 | ) => |
| 552 | Effect.gen(function* () { |
| 553 | const result = yield* ec2 |
| 554 | .describeVolumes({ VolumeIds: [volumeId] }) |
| 555 | .pipe( |
| 556 | Effect.catchTag("InvalidVolume.NotFound", () => |
| 557 | Effect.succeed({ Volumes: [] }), |
| 558 | ), |
| 559 | ); |
| 560 | const volume = result.Volumes?.[0]; |
| 561 | // Do not treat `deleting` as deleted. The provider's delete contract is |
| 562 | // complete only after EC2 stops returning the volume; otherwise nuke can |
| 563 | // report success while the resource remains in its inventory. |
| 564 | if (!volume || volume.State === "deleted") { |
| 565 | return; |
| 566 | } |
| 567 | return yield* new VolumeStillExists({ |
| 568 | volumeId, |
| 569 | state: volume.State ?? "unknown", |
| 570 | message: |
| 571 | `Volume ${volumeId} remains ${volume.State ?? "visible"} after the bounded delete wait; ` + |
| 572 | "AWS accepted DeleteVolume but has not removed it", |
| 573 | }); |
| 574 | }).pipe( |
| 575 | Effect.retry({ |
| 576 | while: (e) => e instanceof VolumeStillExists, |
| 577 | schedule: Schedule.max([ |
| 578 | Schedule.fixed(2000), |
| 579 | // DeleteVolume is accepted immediately but describe can keep |
| 580 | // returning `deleting` well past a minute after a snapshot (the |
| 581 | // suite log hit the old 60s cap with the volume still present). |
| 582 | Schedule.recurs(45), |
| 583 | ]).pipe( |
| 584 | Schedule.tap(({ attempt }) => |
| 585 | session.note( |
| 586 | `Waiting for volume deletion... (${(attempt + 1) * 2}s)`, |
| 587 | ), |
| 588 | ), |
| 589 | ), |
| 590 | }), |
| 591 | ); |