| 1 | import { Region } from "@distilled.cloud/aws/Region"; |
| 2 | import type { BucketLocationConstraint } from "@distilled.cloud/aws/s3"; |
| 3 | import * as s3 from "@distilled.cloud/aws/s3"; |
| 4 | import * as Arr from "effect/Array"; |
| 5 | import * as Data from "effect/Data"; |
| 6 | import type * as Duration from "effect/Duration"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as Order from "effect/Order"; |
| 9 | import * as Redacted from "effect/Redacted"; |
| 10 | import * as Schedule from "effect/Schedule"; |
| 11 | import * as Stream from "effect/Stream"; |
| 12 | import { createHash } from "node:crypto"; |
| 13 | import type { ScopedPlanStatusSession } from "../../Report.ts"; |
| 14 | import { isResolved } from "../../Diff.ts"; |
| 15 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 16 | import * as Provider from "../../Provider.ts"; |
| 17 | import { Resource, type ResourceBinding } from "../../Resource.ts"; |
| 18 | import { Stack } from "../../Stack.ts"; |
| 19 | import { isActionState, State } from "../../State/State.ts"; |
| 20 | import { diffTags } from "../../Tags.ts"; |
| 21 | import { AWSEnvironment, type AccountID } from "../Environment.ts"; |
| 22 | import { durationToDays } from "../IAM/common.ts"; |
| 23 | import type { PolicyStatement } from "../IAM/Policy.ts"; |
| 24 | import type { Providers } from "../Providers.ts"; |
| 25 | import type { RegionID } from "../Region.ts"; |
| 26 | |
| 27 | export type BucketName = string; |
| 28 | export type BucketArn = `arn:aws:s3:::${BucketName}`; |
| 29 | |
| 30 | /** |
| 31 | * Server-side encryption configuration for a bucket. |
| 32 | */ |
| 33 | export interface BucketEncryption { |
| 34 | /** |
| 35 | * Server-side encryption algorithm to use for the default encryption. |
| 36 | */ |
| 37 | sseAlgorithm: "AES256" | "aws:kms" | "aws:kms:dsse"; |
| 38 | /** |
| 39 | * KMS key id (or ARN) to use when `sseAlgorithm` is `aws:kms` or |
| 40 | * `aws:kms:dsse`. Ignored for `AES256`. |
| 41 | */ |
| 42 | kmsMasterKeyId?: string; |
| 43 | /** |
| 44 | * Whether to use an S3 Bucket Key for SSE-KMS to reduce KMS request costs. |
| 45 | * @default false |
| 46 | */ |
| 47 | bucketKeyEnabled?: boolean; |
| 48 | /** |
| 49 | * Encryption types to block for new object writes. Currently supports SSE-C |
| 50 | * (server-side encryption with customer-provided keys). |
| 51 | * Omitted or `[]` blocks no encryption types and permits SSE-C, sending |
| 52 | * AWS's `NONE` value. Set to `["SSE-C"]` to block customer-provided keys. |
| 53 | * Removing this property resets the blocklist to its default. |
| 54 | * @default [] |
| 55 | */ |
| 56 | blockedEncryptionTypes?: "SSE-C"[]; |
| 57 | } |
| 58 | |
| 59 | /** |
| 60 | * Public access block settings for a bucket. Each flag defaults to `false` |
| 61 | * (i.e. the corresponding public access is allowed) when omitted. |
| 62 | */ |
| 63 | export interface BucketPublicAccessBlock { |
| 64 | /** Block new public ACLs and uploading public objects. */ |
| 65 | blockPublicAcls?: boolean; |
| 66 | /** Ignore all public ACLs on the bucket and its objects. */ |
| 67 | ignorePublicAcls?: boolean; |
| 68 | /** Block new bucket policies that grant public access. */ |
| 69 | blockPublicPolicy?: boolean; |
| 70 | /** Restrict access granted by public bucket policies to AWS principals. */ |
| 71 | restrictPublicBuckets?: boolean; |
| 72 | } |
| 73 | |
| 74 | /** |
| 75 | * Access-logging configuration for a bucket. |
| 76 | */ |
| 77 | export interface BucketLogging { |
| 78 | /** Bucket that receives the access logs. */ |
| 79 | targetBucket: string; |
| 80 | /** Key prefix applied to log object names. */ |
| 81 | targetPrefix: string; |
| 82 | /** Optional grants giving accounts access to the log objects. */ |
| 83 | targetGrants?: s3.TargetGrant[]; |
| 84 | /** Optional log object key format (simple or partitioned prefix). */ |
| 85 | targetObjectKeyFormat?: s3.TargetObjectKeyFormat; |
| 86 | } |
| 87 | |
| 88 | /** |
| 89 | * Static-website hosting configuration for a bucket. |
| 90 | */ |
| 91 | export interface BucketWebsite { |
| 92 | /** Index document served for directory-style requests. */ |
| 93 | indexDocument?: { suffix: string }; |
| 94 | /** Document served for 4XX errors. */ |
| 95 | errorDocument?: { key: string }; |
| 96 | /** Redirect every request to another host instead of serving objects. */ |
| 97 | redirectAllRequestsTo?: { hostName: string; protocol?: "http" | "https" }; |
| 98 | /** Routing rules for conditional redirects. */ |
| 99 | routingRules?: s3.RoutingRule[]; |
| 100 | } |
| 101 | |
| 102 | /** |
| 103 | * Cross-region (or same-region) replication configuration for a bucket. |
| 104 | * Requires `versioning: "Enabled"` on the source bucket and an IAM role |
| 105 | * that S3 can assume to perform the replication. |
| 106 | */ |
| 107 | export interface BucketReplication { |
| 108 | /** ARN of the IAM role S3 assumes to replicate objects. */ |
| 109 | role: string; |
| 110 | /** Replication rules describing what to replicate and where. */ |
| 111 | rules: s3.ReplicationRule[]; |
| 112 | } |
| 113 | |
| 114 | /** |
| 115 | * Default object-lock retention applied to objects placed in a bucket that |
| 116 | * was created with `objectLockEnabled: true`. |
| 117 | */ |
| 118 | export interface BucketObjectLockConfiguration { |
| 119 | /** Retention mode. */ |
| 120 | mode: "GOVERNANCE" | "COMPLIANCE"; |
| 121 | /** |
| 122 | * Retention period (e.g. `"30 days"` or `Duration.days(30)`; a bare number |
| 123 | * is milliseconds). Rounded to whole days on the wire. Mutually exclusive |
| 124 | * with `years`. |
| 125 | */ |
| 126 | days?: Duration.Input; |
| 127 | /** Retention period in years (mutually exclusive with `days`). */ |
| 128 | years?: number; |
| 129 | } |
| 130 | |
| 131 | export interface BucketProps { |
| 132 | /** |
| 133 | * Name of the bucket. If omitted, a unique name will be generated. |
| 134 | * Must be lowercase and between 3-63 characters. |
| 135 | */ |
| 136 | bucketName?: string; |
| 137 | /** |
| 138 | * Indicates whether this bucket has Object Lock enabled. |
| 139 | * Once enabled, cannot be disabled. |
| 140 | */ |
| 141 | objectLockEnabled?: boolean; |
| 142 | /** |
| 143 | * Whether to delete all objects when the bucket is destroyed. |
| 144 | * @default false |
| 145 | */ |
| 146 | forceDestroy?: boolean; |
| 147 | /** |
| 148 | * Tags to apply to the bucket. |
| 149 | */ |
| 150 | tags?: Record<string, string>; |
| 151 | /** |
| 152 | * Object versioning status. `"Enabled"` keeps every version of an object; |
| 153 | * `"Suspended"` stops accruing new versions (existing versions are kept). |
| 154 | */ |
| 155 | versioning?: "Enabled" | "Suspended"; |
| 156 | /** |
| 157 | * MFA-delete status. Rarely used — enabling it requires an MFA serial and |
| 158 | * the root account, so it cannot be toggled through normal credentials. |
| 159 | */ |
| 160 | mfaDelete?: "Enabled" | "Disabled"; |
| 161 | /** |
| 162 | * Default server-side encryption for new objects. Omission restores AES256, |
| 163 | * no KMS key, bucket keys disabled, and no encryption types blocked. |
| 164 | * Existing objects are not re-encrypted. External changes are repaired on deploy. |
| 165 | */ |
| 166 | encryption?: BucketEncryption; |
| 167 | /** |
| 168 | * Block-public-access settings. Applied before any ACL or policy that |
| 169 | * grants public access. |
| 170 | */ |
| 171 | publicAccessBlock?: BucketPublicAccessBlock; |
| 172 | /** |
| 173 | * Cross-origin resource sharing (CORS) rules. |
| 174 | */ |
| 175 | cors?: s3.CORSRule[]; |
| 176 | /** |
| 177 | * Object lifecycle rules (expiration, transition, abort-incomplete-MPU…). |
| 178 | */ |
| 179 | lifecycleRules?: s3.LifecycleRule[]; |
| 180 | /** |
| 181 | * Object ownership control. `"BucketOwnerEnforced"` disables ACLs entirely. |
| 182 | */ |
| 183 | objectOwnership?: |
| 184 | | "BucketOwnerPreferred" |
| 185 | | "ObjectWriter" |
| 186 | | "BucketOwnerEnforced"; |
| 187 | /** |
| 188 | * Canned ACL to apply. Only valid when object ownership is not |
| 189 | * `BucketOwnerEnforced`. |
| 190 | */ |
| 191 | acl?: s3.BucketCannedACL; |
| 192 | /** |
| 193 | * Access-logging configuration. |
| 194 | */ |
| 195 | logging?: BucketLogging; |
| 196 | /** |
| 197 | * S3 Transfer Acceleration status. |
| 198 | */ |
| 199 | transferAcceleration?: "Enabled" | "Suspended"; |
| 200 | /** |
| 201 | * Who pays for requests and data transfer. `"Requester"` enables |
| 202 | * requester-pays. |
| 203 | */ |
| 204 | requestPayer?: "BucketOwner" | "Requester"; |
| 205 | /** |
| 206 | * Static-website hosting configuration. |
| 207 | */ |
| 208 | website?: BucketWebsite; |
| 209 | /** |
| 210 | * Replication configuration. Requires `versioning: "Enabled"` and an |
| 211 | * IAM role. |
| 212 | */ |
| 213 | replication?: BucketReplication; |
| 214 | /** |
| 215 | * S3 Intelligent-Tiering configurations (id-keyed). |
| 216 | */ |
| 217 | intelligentTiering?: s3.IntelligentTieringConfiguration[]; |
| 218 | /** |
| 219 | * Default object-lock retention. Requires `objectLockEnabled: true`. |
| 220 | */ |
| 221 | objectLockConfiguration?: BucketObjectLockConfiguration; |
| 222 | /** |
| 223 | * Explicit bucket policy as policy statements. Merged with any |
| 224 | * policy statements contributed via bindings. |
| 225 | */ |
| 226 | policy?: PolicyStatement[]; |
| 227 | } |
| 228 | |
| 229 | export interface Bucket extends Resource< |
| 230 | "AWS.S3.Bucket", |
| 231 | BucketProps, |
| 232 | { |
| 233 | /** |
| 234 | * Name of the bucket. |
| 235 | */ |
| 236 | bucketName: BucketName; |
| 237 | /** |
| 238 | * ARN of the bucket. |
| 239 | */ |
| 240 | bucketArn: BucketArn; |
| 241 | /** |
| 242 | * Domain name of the bucket (e.g., bucket-name.s3.amazonaws.com). |
| 243 | */ |
| 244 | bucketDomainName: `${BucketName}.s3.amazonaws.com`; |
| 245 | /** |
| 246 | * Regional domain name of the bucket. |
| 247 | */ |
| 248 | bucketRegionalDomainName: `${BucketName}.s3.${RegionID}.amazonaws.com`; |
| 249 | /** |
| 250 | * AWS region where the bucket is located. |
| 251 | */ |
| 252 | region: RegionID; |
| 253 | /** |
| 254 | * AWS account ID that owns the bucket. |
| 255 | */ |
| 256 | accountId: AccountID; |
| 257 | /** |
| 258 | * Notification configuration requested through bindings, retained for drift |
| 259 | * detection when binding outputs are unresolved during planning. |
| 260 | */ |
| 261 | managedNotificationConfiguration?: s3.NotificationConfiguration; |
| 262 | }, |
| 263 | { |
| 264 | /** |
| 265 | * Notification configuration for the bucket. Alchemy assigns stable target |
| 266 | * IDs and reconciles Lambda, queue, topic, and EventBridge bindings. |
| 267 | */ |
| 268 | notificationConfiguration?: s3.NotificationConfiguration; |
| 269 | /** |
| 270 | * Policy statements for the bucket. |
| 271 | */ |
| 272 | policyStatements?: PolicyStatement[]; |
| 273 | }, |
| 274 | Providers |
| 275 | > {} |
| 276 | |
| 277 | /** |
| 278 | * An S3 bucket for storing objects in AWS. |
| 279 | * |
| 280 | * A bucket name is auto-generated from the app, stage, and logical ID unless |
| 281 | * you provide one explicitly via `bucketName`. Enable `forceDestroy` to allow |
| 282 | * Alchemy to empty the bucket before deleting it. |
| 283 | * ### Creating a Bucket |
| 284 | * **Example:** Basic Bucket |
| 285 | * ```typescript |
| 286 | * import * as S3 from "alchemy/AWS/S3"; |
| 287 | * |
| 288 | * const bucket = yield* S3.Bucket("my-bucket", {}); |
| 289 | * ``` |
| 290 | * |
| 291 | * **Example:** Bucket with a custom name |
| 292 | * ```typescript |
| 293 | * const bucket = yield* S3.Bucket("my-bucket", { |
| 294 | * bucketName: "my-company-assets", |
| 295 | * }); |
| 296 | * ``` |
| 297 | * |
| 298 | * **Example:** Bucket with force destroy |
| 299 | * ```typescript |
| 300 | * const bucket = yield* S3.Bucket("my-bucket", { |
| 301 | * forceDestroy: true, |
| 302 | * }); |
| 303 | * ``` |
| 304 | * |
| 305 | * ### Configuring a Bucket |
| 306 | * **Example:** Versioning and encryption |
| 307 | * ```typescript |
| 308 | * const bucket = yield* S3.Bucket("my-bucket", { |
| 309 | * versioning: "Enabled", |
| 310 | * encryption: { sseAlgorithm: "AES256" }, |
| 311 | * }); |
| 312 | * ``` |
| 313 | * |
| 314 | * **Example:** Block all public access |
| 315 | * ```typescript |
| 316 | * const bucket = yield* S3.Bucket("my-bucket", { |
| 317 | * publicAccessBlock: { |
| 318 | * blockPublicAcls: true, |
| 319 | * ignorePublicAcls: true, |
| 320 | * blockPublicPolicy: true, |
| 321 | * restrictPublicBuckets: true, |
| 322 | * }, |
| 323 | * }); |
| 324 | * ``` |
| 325 | * |
| 326 | * **Example:** CORS and lifecycle rules |
| 327 | * ```typescript |
| 328 | * const bucket = yield* S3.Bucket("my-bucket", { |
| 329 | * cors: [ |
| 330 | * { |
| 331 | * AllowedMethods: ["GET"], |
| 332 | * AllowedOrigins: ["*"], |
| 333 | * AllowedHeaders: ["*"], |
| 334 | * MaxAgeSeconds: 3000, |
| 335 | * }, |
| 336 | * ], |
| 337 | * lifecycleRules: [ |
| 338 | * { |
| 339 | * ID: "expire-old", |
| 340 | * Status: "Enabled", |
| 341 | * Filter: { Prefix: "logs/" }, |
| 342 | * Expiration: { Days: 30 }, |
| 343 | * }, |
| 344 | * ], |
| 345 | * }); |
| 346 | * ``` |
| 347 | * |
| 348 | * **Example:** Static website hosting |
| 349 | * ```typescript |
| 350 | * const bucket = yield* S3.Bucket("my-bucket", { |
| 351 | * objectOwnership: "BucketOwnerPreferred", |
| 352 | * website: { |
| 353 | * indexDocument: { suffix: "index.html" }, |
| 354 | * errorDocument: { key: "error.html" }, |
| 355 | * }, |
| 356 | * }); |
| 357 | * ``` |
| 358 | * |
| 359 | * ### Default Bucket Encryption |
| 360 | * **Example:** Use the default encryption settings |
| 361 | * ```typescript |
| 362 | * const bucket = yield* S3.Bucket("my-bucket", {}); |
| 363 | * ``` |
| 364 | * |
| 365 | * The default is AES256 encryption with S3-managed keys, bucket keys disabled, |
| 366 | * and no encryption types blocked. Requests may explicitly supply their own |
| 367 | * encryption keys (SSE-C); permitting SSE-C does not change the default |
| 368 | * encryption used by requests without those keys. |
| 369 | * |
| 370 | * ### Blocking Customer-Provided Encryption Keys |
| 371 | * **Example:** Reject new SSE-C writes |
| 372 | * ```typescript |
| 373 | * const bucket = yield* S3.Bucket("my-bucket", { |
| 374 | * encryption: { |
| 375 | * sseAlgorithm: "AES256", |
| 376 | * blockedEncryptionTypes: ["SSE-C"], |
| 377 | * }, |
| 378 | * }); |
| 379 | * ``` |
| 380 | * |
| 381 | * `blockedEncryptionTypes` lists encryption types to reject. This blocks new |
| 382 | * writes using customer-provided keys while retaining AES256 default encryption. |
| 383 | * Existing encrypted objects are unchanged. |
| 384 | * |
| 385 | * ### Resetting Encryption Restrictions |
| 386 | * **Example:** Remove the block to restore the default |
| 387 | * ```diff |
| 388 | * const bucket = yield* S3.Bucket("my-bucket", { |
| 389 | * encryption: { |
| 390 | * sseAlgorithm: "AES256", |
| 391 | * - blockedEncryptionTypes: ["SSE-C"], |
| 392 | * }, |
| 393 | * }); |
| 394 | * ``` |
| 395 | * |
| 396 | * Redeploy the same logical resource after removing the property. Alchemy |
| 397 | * resets the blocklist to its default, `[]`, so SSE-C writes are permitted. |
| 398 | * It updates AWS rather than preserving the previously configured block. |
| 399 | * |
| 400 | * **Example:** Set the default blocklist explicitly |
| 401 | * ```typescript |
| 402 | * const bucket = yield* S3.Bucket("my-bucket", { |
| 403 | * encryption: { |
| 404 | * sseAlgorithm: "AES256", |
| 405 | * blockedEncryptionTypes: [], |
| 406 | * }, |
| 407 | * }); |
| 408 | * ``` |
| 409 | * |
| 410 | * `[]` and omission have the same desired state: no encryption types blocked. |
| 411 | * The provider sends AWS's `NONE` value when it needs to reset a restriction. |
| 412 | * Redeploying unchanged code also repairs externally modified restrictions. |
| 413 | * |
| 414 | * ### Resetting All Encryption Settings |
| 415 | * **Example:** Remove the entire encryption configuration |
| 416 | * ```diff |
| 417 | * const bucket = yield* S3.Bucket("my-bucket", { |
| 418 | * - encryption: { |
| 419 | * - sseAlgorithm: "aws:kms", |
| 420 | * - kmsMasterKeyId: "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012", |
| 421 | * - bucketKeyEnabled: true, |
| 422 | * - blockedEncryptionTypes: ["SSE-C"], |
| 423 | * - }, |
| 424 | * }); |
| 425 | * ``` |
| 426 | * |
| 427 | * Omitting `encryption` resets every setting to the defaults: AES256, no custom |
| 428 | * KMS key, bucket keys disabled, and an empty blocklist. Previously configured |
| 429 | * KMS encryption is also reset; existing objects are not re-encrypted. |
| 430 | * |
| 431 | * ### Runtime Operations |
| 432 | * Bind S3 operations in the init phase and use them in runtime |
| 433 | * handlers. Bindings inject the bucket name and grant scoped IAM |
| 434 | * permissions automatically. |
| 435 | * |
| 436 | * **Example:** Read and write objects |
| 437 | * ```typescript |
| 438 | * // init |
| 439 | * const getObject = yield* S3.GetObject(bucket); |
| 440 | * const putObject = yield* S3.PutObject(bucket); |
| 441 | * |
| 442 | * return { |
| 443 | * fetch: Effect.gen(function* () { |
| 444 | * // runtime |
| 445 | * yield* putObject({ |
| 446 | * Key: "hello.txt", |
| 447 | * Body: "Hello, World!", |
| 448 | * ContentType: "text/plain", |
| 449 | * }); |
| 450 | * const response = yield* getObject({ Key: "hello.txt" }); |
| 451 | * return HttpServerResponse.text("OK"); |
| 452 | * }), |
| 453 | * }; |
| 454 | * ``` |
| 455 | * |
| 456 | * **Example:** Delete an object |
| 457 | * ```typescript |
| 458 | * // init |
| 459 | * const deleteObject = yield* S3.DeleteObject(bucket); |
| 460 | * ``` |
| 461 | * |
| 462 | * ### Event Notifications |
| 463 | * Subscribe to bucket events from the init phase. The runtime binding creates |
| 464 | * the delivery permissions: Lambda invokes directly, while servers consume an SQS queue. |
| 465 | * |
| 466 | * Notification bindings reconcile Lambda, SQS, SNS, and EventBridge destinations. |
| 467 | * Removing a binding removes the targets Alchemy manages while preserving external |
| 468 | * configurations, including identical pre-existing targets. EventBridge ownership |
| 469 | * is recorded in a reserved bucket tag so interrupted deployments can recover. |
| 470 | * |
| 471 | * **Example:** Process object creation events |
| 472 | * ```typescript |
| 473 | * // init |
| 474 | * yield* S3.consumeBucketEvents(bucket, { |
| 475 | * events: ["s3:ObjectCreated:*"], |
| 476 | * }, (stream) => |
| 477 | * stream.pipe( |
| 478 | * Stream.runForEach((event) => |
| 479 | * Effect.log(`New object: ${event.key}`), |
| 480 | * ), |
| 481 | * ), |
| 482 | * ); |
| 483 | * ``` |
| 484 | * |
| 485 | * @resource |
| 486 | */ |
| 487 | export const Bucket = Resource<Bucket>("AWS.S3.Bucket"); |
| 488 | |
| 489 | export const BucketProvider = () => |
| 490 | Provider.effect( |
| 491 | Bucket, |
| 492 | Effect.gen(function* () { |
| 493 | const createBucketName = ( |
| 494 | id: string, |
| 495 | props: { bucketName?: string | undefined }, |
| 496 | ) => |
| 497 | Effect.gen(function* () { |
| 498 | if (props.bucketName) { |
| 499 | return props.bucketName; |
| 500 | } |
| 501 | return yield* createPhysicalName({ |
| 502 | id, |
| 503 | maxLength: 63, |
| 504 | lowercase: true, |
| 505 | }); |
| 506 | }); |
| 507 | |
| 508 | const deleteAllObjects = Effect.fn(function* (bucketName: string) { |
| 509 | yield* Effect.logInfo( |
| 510 | `S3 Bucket delete: deleting all objects from ${bucketName}`, |
| 511 | ); |
| 512 | // Delete every object VERSION and DELETE MARKER, by Key+VersionId. |
| 513 | // `DeleteBucket` fails with BucketNotEmpty until all of them are |
| 514 | // gone — deleting only current objects (listObjectsV2) leaves |
| 515 | // versions/markers behind in versioned buckets. listObjectVersions |
| 516 | // also covers unversioned buckets (plain objects surface as |
| 517 | // Versions with VersionId "null"), so this single loop empties |
| 518 | // both. Pagination is gated on IsTruncated — never on NextKeyMarker, |
| 519 | // which S3 may omit even mid-listing. |
| 520 | let isTruncated = true; |
| 521 | let keyMarker: string | undefined; |
| 522 | let versionIdMarker: string | undefined; |
| 523 | while (isTruncated) { |
| 524 | const page = yield* s3.listObjectVersions({ |
| 525 | Bucket: bucketName, |
| 526 | KeyMarker: keyMarker, |
| 527 | VersionIdMarker: versionIdMarker, |
| 528 | }); |
| 529 | |
| 530 | const objectsToDelete = [ |
| 531 | ...(page.Versions ?? []).map((v) => ({ |
| 532 | Key: v.Key!, |
| 533 | VersionId: v.VersionId, |
| 534 | })), |
| 535 | ...(page.DeleteMarkers ?? []).map((dm) => ({ |
| 536 | Key: dm.Key!, |
| 537 | VersionId: dm.VersionId, |
| 538 | })), |
| 539 | ]; |
| 540 | |
| 541 | if (objectsToDelete.length > 0) { |
| 542 | yield* Effect.logInfo( |
| 543 | `S3 Bucket delete: deleting ${objectsToDelete.length} object version(s)/delete marker(s) from ${bucketName}`, |
| 544 | ); |
| 545 | yield* s3.deleteObjects({ |
| 546 | Bucket: bucketName, |
| 547 | Delete: { |
| 548 | Objects: objectsToDelete, |
| 549 | Quiet: true, |
| 550 | }, |
| 551 | }); |
| 552 | } |
| 553 | |
| 554 | isTruncated = page.IsTruncated === true; |
| 555 | keyMarker = page.NextKeyMarker; |
| 556 | versionIdMarker = page.NextVersionIdMarker; |
| 557 | } |
| 558 | |
| 559 | // Abort any in-progress multipart uploads — their stored parts also |
| 560 | // keep DeleteBucket failing with BucketNotEmpty. |
| 561 | let uploadsTruncated = true; |
| 562 | let uploadKeyMarker: string | undefined; |
| 563 | let uploadIdMarker: string | undefined; |
| 564 | while (uploadsTruncated) { |
| 565 | const uploads = yield* s3.listMultipartUploads({ |
| 566 | Bucket: bucketName, |
| 567 | KeyMarker: uploadKeyMarker, |
| 568 | UploadIdMarker: uploadIdMarker, |
| 569 | }); |
| 570 | const inProgress = (uploads.Uploads ?? []).filter( |
| 571 | (u) => u.Key != null && u.UploadId != null, |
| 572 | ); |
| 573 | if (inProgress.length > 0) { |
| 574 | yield* Effect.logInfo( |
| 575 | `S3 Bucket delete: aborting ${inProgress.length} in-progress multipart upload(s) in ${bucketName}`, |
| 576 | ); |
| 577 | yield* Effect.all( |
| 578 | inProgress.map((u) => |
| 579 | s3 |
| 580 | .abortMultipartUpload({ |
| 581 | Bucket: bucketName, |
| 582 | Key: u.Key!, |
| 583 | UploadId: u.UploadId!, |
| 584 | }) |
| 585 | // Already aborted/completed concurrently — a race, not a |
| 586 | // failure. |
| 587 | .pipe(Effect.catchTag("NoSuchUpload", () => Effect.void)), |
| 588 | ), |
| 589 | { concurrency: 8 }, |
| 590 | ); |
| 591 | } |
| 592 | uploadsTruncated = uploads.IsTruncated === true; |
| 593 | uploadKeyMarker = uploads.NextKeyMarker; |
| 594 | uploadIdMarker = uploads.NextUploadIdMarker; |
| 595 | } |
| 596 | }); |
| 597 | |
| 598 | const ensureBucketExists = Effect.fn(function* ({ |
| 599 | id, |
| 600 | news = {}, |
| 601 | }: { |
| 602 | id: string; |
| 603 | news: BucketProps; |
| 604 | }) { |
| 605 | const { region } = yield* AWSEnvironment.current; |
| 606 | const { accountId } = yield* AWSEnvironment.current; |
| 607 | const bucketName = yield* createBucketName(id, news); |
| 608 | |
| 609 | yield* Effect.logInfo( |
| 610 | `S3 Bucket create: bucket=${bucketName} region=${region} `, |
| 611 | ); |
| 612 | |
| 613 | // For us-east-1, BucketAlreadyOwnedByYou is not thrown, so we need to |
| 614 | // pre-emptively check if the bucket exists for idempotency |
| 615 | if (region === "us-east-1") { |
| 616 | const exists = yield* s3 |
| 617 | .getBucketLocation({ |
| 618 | Bucket: bucketName, |
| 619 | ExpectedBucketOwner: accountId, |
| 620 | }) |
| 621 | .pipe( |
| 622 | Effect.map(() => true), |
| 623 | Effect.catchTag("NoSuchBucket", () => Effect.succeed(false)), |
| 624 | ); |
| 625 | |
| 626 | yield* Effect.logInfo( |
| 627 | `S3 Bucket create: us-east-1 existence check for ${bucketName} -> ${exists}`, |
| 628 | ); |
| 629 | |
| 630 | if (!exists) { |
| 631 | yield* Effect.logInfo( |
| 632 | `S3 Bucket create: creating bucket ${bucketName} in us-east-1`, |
| 633 | ); |
| 634 | yield* s3 |
| 635 | .createBucket({ |
| 636 | Bucket: bucketName, |
| 637 | ObjectLockEnabledForBucket: news.objectLockEnabled ?? false, |
| 638 | }) |
| 639 | .pipe( |
| 640 | Effect.retry({ |
| 641 | while: (e) => |
| 642 | e._tag === "OperationAborted" || |
| 643 | e._tag === "ServiceUnavailable", |
| 644 | schedule: Schedule.exponential(100), |
| 645 | }), |
| 646 | ); |
| 647 | } |
| 648 | } else { |
| 649 | // For non-us-east-1 regions, we can rely on BucketAlreadyOwnedByYou |
| 650 | yield* Effect.logInfo( |
| 651 | `S3 Bucket create: creating bucket ${bucketName} in ${region}`, |
| 652 | ); |
| 653 | yield* s3 |
| 654 | .createBucket({ |
| 655 | Bucket: bucketName, |
| 656 | CreateBucketConfiguration: { |
| 657 | LocationConstraint: region as BucketLocationConstraint, |
| 658 | }, |
| 659 | ObjectLockEnabledForBucket: news.objectLockEnabled, |
| 660 | }) |
| 661 | .pipe( |
| 662 | Effect.catchTag("BucketAlreadyOwnedByYou", () => Effect.void), |
| 663 | Effect.retry({ |
| 664 | while: (e) => |
| 665 | e._tag === "OperationAborted" || |
| 666 | e._tag === "ServiceUnavailable", |
| 667 | schedule: Schedule.exponential(100), |
| 668 | }), |
| 669 | ); |
| 670 | } |
| 671 | |
| 672 | // Wait for bucket to exist (eventual consistency) |
| 673 | yield* s3 |
| 674 | .getBucketLocation({ |
| 675 | Bucket: bucketName, |
| 676 | ExpectedBucketOwner: accountId, |
| 677 | }) |
| 678 | .pipe( |
| 679 | Effect.retry({ |
| 680 | while: (error) => error._tag === "NoSuchBucket", |
| 681 | schedule: Schedule.exponential(100), |
| 682 | times: 8, |
| 683 | }), |
| 684 | ); |
| 685 | yield* Effect.logInfo( |
| 686 | `S3 Bucket create: bucket is available ${bucketName}`, |
| 687 | ); |
| 688 | |
| 689 | return { |
| 690 | bucketName, |
| 691 | bucketArn: `arn:aws:s3:::${bucketName}` as const, |
| 692 | bucketDomainName: `${bucketName}.s3.amazonaws.com` as const, |
| 693 | bucketRegionalDomainName: |
| 694 | `${bucketName}.s3.${region}.amazonaws.com` as const, |
| 695 | region, |
| 696 | accountId, |
| 697 | }; |
| 698 | }); |
| 699 | |
| 700 | const fetchBucketTags = (bucketName: string) => |
| 701 | s3.getBucketTagging({ Bucket: bucketName }).pipe( |
| 702 | Effect.map((r) => |
| 703 | Object.fromEntries((r.TagSet ?? []).map((t) => [t.Key!, t.Value!])), |
| 704 | ), |
| 705 | Effect.catchTag("NoSuchTagSet", () => |
| 706 | Effect.succeed<Record<string, string>>({}), |
| 707 | ), |
| 708 | ); |
| 709 | |
| 710 | const eventBridgeOwnerTag = "alchemy:notifications:eventbridge"; |
| 711 | const notificationOwner = (bucketName: string) => |
| 712 | Effect.sync( |
| 713 | () => |
| 714 | `alchemy-notification-${createHash("sha256").update(bucketName).digest("hex").slice(0, 16)}-`, |
| 715 | ); |
| 716 | |
| 717 | const syncBucketTags = Effect.fn(function* ({ |
| 718 | bucketName, |
| 719 | oldTags, |
| 720 | newTags, |
| 721 | session, |
| 722 | operation, |
| 723 | }: { |
| 724 | bucketName: string; |
| 725 | oldTags?: Record<string, string>; |
| 726 | newTags?: Record<string, string>; |
| 727 | session: ScopedPlanStatusSession; |
| 728 | operation: "create" | "update"; |
| 729 | }) { |
| 730 | // Compare against the cloud's actual tags so drift surfaces |
| 731 | // correctly even after a cold-start adoption (where olds.tags |
| 732 | // equals news.tags and would otherwise look like a no-op). |
| 733 | const previousTags = oldTags ?? (yield* fetchBucketTags(bucketName)); |
| 734 | const desiredTags = { ...newTags }; |
| 735 | delete desiredTags[eventBridgeOwnerTag]; |
| 736 | if (previousTags[eventBridgeOwnerTag]) { |
| 737 | desiredTags[eventBridgeOwnerTag] = previousTags[eventBridgeOwnerTag]; |
| 738 | } |
| 739 | const { removed, upsert } = diffTags(previousTags, desiredTags); |
| 740 | const canSkip = oldTags !== undefined; |
| 741 | |
| 742 | yield* Effect.logInfo( |
| 743 | `S3 Bucket ${operation}: bucket=${bucketName} removedTags=${removed.length} upsertTags=${Object.keys(upsert).length}`, |
| 744 | ); |
| 745 | |
| 746 | if ( |
| 747 | canSkip && |
| 748 | removed.length === 0 && |
| 749 | Object.keys(upsert).length === 0 |
| 750 | ) { |
| 751 | return; |
| 752 | } |
| 753 | |
| 754 | if (Object.keys(desiredTags).length > 0) { |
| 755 | yield* Effect.logInfo( |
| 756 | `S3 Bucket ${operation}: writing ${Object.keys(desiredTags).length} total tag(s) to ${bucketName}`, |
| 757 | ); |
| 758 | yield* s3.putBucketTagging({ |
| 759 | Bucket: bucketName, |
| 760 | Tagging: { |
| 761 | TagSet: Object.entries(desiredTags).map(([Key, Value]) => ({ |
| 762 | Key, |
| 763 | Value, |
| 764 | })), |
| 765 | }, |
| 766 | }); |
| 767 | yield* session.note(`Updated bucket tags: ${bucketName}`); |
| 768 | return; |
| 769 | } |
| 770 | |
| 771 | yield* Effect.logInfo( |
| 772 | `S3 Bucket ${operation}: removing all tags from ${bucketName}`, |
| 773 | ); |
| 774 | yield* s3.deleteBucketTagging({ |
| 775 | Bucket: bucketName, |
| 776 | }); |
| 777 | yield* session.note(`Removed all tags from bucket: ${bucketName}`); |
| 778 | }); |
| 779 | |
| 780 | const syncBucketPolicy = Effect.fn(function* ({ |
| 781 | bucketName, |
| 782 | bindings, |
| 783 | explicitStatements, |
| 784 | session, |
| 785 | operation, |
| 786 | }: { |
| 787 | bucketName: string; |
| 788 | session: ScopedPlanStatusSession; |
| 789 | bindings: ResourceBinding<Bucket["Binding"]>[]; |
| 790 | explicitStatements?: PolicyStatement[]; |
| 791 | operation: "create" | "update"; |
| 792 | }) { |
| 793 | const policyStatements = [ |
| 794 | ...(explicitStatements ?? []), |
| 795 | ...bindings.flatMap((binding) => binding.data.policyStatements ?? []), |
| 796 | ]; |
| 797 | const desiredPolicy = |
| 798 | policyStatements.length > 0 |
| 799 | ? JSON.stringify({ |
| 800 | Version: "2012-10-17", |
| 801 | Statement: policyStatements, |
| 802 | }) |
| 803 | : undefined; |
| 804 | const existingPolicy = yield* s3 |
| 805 | .getBucketPolicy({ Bucket: bucketName }) |
| 806 | .pipe( |
| 807 | Effect.map((r) => r.Policy), |
| 808 | Effect.catchTag("NoSuchBucketPolicy", () => |
| 809 | Effect.succeed<string | undefined>(undefined), |
| 810 | ), |
| 811 | ); |
| 812 | |
| 813 | yield* Effect.logInfo( |
| 814 | `S3 Bucket ${operation}: bucket=${bucketName} policyStatements=${policyStatements.length}`, |
| 815 | ); |
| 816 | |
| 817 | if (desiredPolicy) { |
| 818 | if (existingPolicy === desiredPolicy) { |
| 819 | return; |
| 820 | } |
| 821 | |
| 822 | yield* Effect.logInfo( |
| 823 | `S3 Bucket ${operation}: applying ${policyStatements.length} policy statement(s) to ${bucketName}`, |
| 824 | ); |
| 825 | yield* s3.putBucketPolicy({ |
| 826 | Bucket: bucketName, |
| 827 | Policy: desiredPolicy, |
| 828 | }); |
| 829 | yield* session.note(`Updated bucket policy: ${bucketName}`); |
| 830 | return; |
| 831 | } |
| 832 | |
| 833 | if (existingPolicy === undefined) { |
| 834 | return; |
| 835 | } |
| 836 | |
| 837 | yield* Effect.logInfo( |
| 838 | `S3 Bucket ${operation}: deleting bucket policy for ${bucketName}`, |
| 839 | ); |
| 840 | yield* s3.deleteBucketPolicy({ Bucket: bucketName }); |
| 841 | yield* session.note(`Removed bucket policy: ${bucketName}`); |
| 842 | }); |
| 843 | |
| 844 | type NotificationTarget = |
| 845 | | s3.LambdaFunctionConfiguration |
| 846 | | s3.QueueConfiguration |
| 847 | | s3.TopicConfiguration; |
| 848 | const notificationTargetKey = (target: NotificationTarget) => |
| 849 | JSON.stringify({ |
| 850 | arn: |
| 851 | "LambdaFunctionArn" in target |
| 852 | ? target.LambdaFunctionArn |
| 853 | : "QueueArn" in target |
| 854 | ? target.QueueArn |
| 855 | : target.TopicArn, |
| 856 | events: [...(target.Events ?? [])].sort(), |
| 857 | filter: (target.Filter?.Key?.FilterRules ?? []) |
| 858 | .map((rule) => [rule.Name?.toLowerCase(), rule.Value]) |
| 859 | .sort(([a, av], [b, bv]) => |
| 860 | `${a}:${av}`.localeCompare(`${b}:${bv}`), |
| 861 | ), |
| 862 | }); |
| 863 | const readManagedNotifications = Effect.fn(function* ( |
| 864 | fqn: string, |
| 865 | instanceId: string, |
| 866 | output: Bucket["Attributes"] | undefined, |
| 867 | ) { |
| 868 | if (!output || output.managedNotificationConfiguration !== undefined) { |
| 869 | return output?.managedNotificationConfiguration; |
| 870 | } |
| 871 | const stack = yield* Stack; |
| 872 | const state = yield* yield* State; |
| 873 | const row = yield* state.get({ |
| 874 | stack: stack.name, |
| 875 | stage: stack.stage, |
| 876 | fqn, |
| 877 | }); |
| 878 | if ( |
| 879 | !row || |
| 880 | isActionState(row) || |
| 881 | row.resourceType !== "AWS.S3.Bucket" || |
| 882 | row.fqn !== fqn || |
| 883 | row.instanceId !== instanceId || |
| 884 | row.attr?.bucketName !== output.bucketName || |
| 885 | (row.status !== "created" && |
| 886 | row.status !== "updated" && |
| 887 | row.status !== "replaced" && |
| 888 | row.status !== "updating") || |
| 889 | (row.status === "updating" && row.adopting) |
| 890 | ) { |
| 891 | return undefined; |
| 892 | } |
| 893 | // An updating row contains the new bindings; only its old snapshot proves ownership. |
| 894 | const previous = row.status === "updating" ? row.old : row; |
| 895 | if ( |
| 896 | previous.attr?.bucketName !== output.bucketName || |
| 897 | previous.attr?.bucketArn !== output.bucketArn || |
| 898 | previous.attr?.accountId !== output.accountId || |
| 899 | previous.attr?.region !== output.region |
| 900 | ) { |
| 901 | return undefined; |
| 902 | } |
| 903 | const managed: s3.NotificationConfiguration | undefined = |
| 904 | previous.attr.managedNotificationConfiguration; |
| 905 | if (managed !== undefined) return managed; |
| 906 | const bindings: ResourceBinding<Bucket["Binding"]>[] = |
| 907 | previous.bindings ?? []; |
| 908 | if (!isResolved(bindings)) return undefined; |
| 909 | const legacy = bindings.flatMap( |
| 910 | ({ data }) => |
| 911 | data.notificationConfiguration?.LambdaFunctionConfigurations ?? [], |
| 912 | ); |
| 913 | if (legacy.length === 0) return undefined; |
| 914 | const observed = yield* s3.getBucketNotificationConfiguration({ |
| 915 | Bucket: output.bucketName, |
| 916 | }); |
| 917 | return { |
| 918 | LambdaFunctionConfigurations: ( |
| 919 | observed.LambdaFunctionConfigurations ?? [] |
| 920 | ).filter((target) => |
| 921 | legacy.some( |
| 922 | (old) => |
| 923 | (old.Id === undefined || old.Id === target.Id) && |
| 924 | notificationTargetKey(old) === notificationTargetKey(target), |
| 925 | ), |
| 926 | ), |
| 927 | } satisfies s3.NotificationConfiguration; |
| 928 | }); |
| 929 | const notificationKey = (config: s3.NotificationConfiguration) => { |
| 930 | const targets = (values: NotificationTarget[] = []) => |
| 931 | values |
| 932 | .map( |
| 933 | (target) => `${target.Id ?? ""}:${notificationTargetKey(target)}`, |
| 934 | ) |
| 935 | .sort(); |
| 936 | return JSON.stringify({ |
| 937 | lambda: targets(config.LambdaFunctionConfigurations), |
| 938 | queue: targets(config.QueueConfigurations), |
| 939 | topic: targets(config.TopicConfigurations), |
| 940 | eventBridge: config.EventBridgeConfiguration !== undefined, |
| 941 | }); |
| 942 | }; |
| 943 | const desiredNotifications = ( |
| 944 | bindings: ResourceBinding<Bucket["Binding"]>[], |
| 945 | owner: string, |
| 946 | ) => |
| 947 | Effect.sync(() => { |
| 948 | const desired: s3.NotificationConfiguration = { |
| 949 | LambdaFunctionConfigurations: [], |
| 950 | QueueConfigurations: [], |
| 951 | TopicConfigurations: [], |
| 952 | }; |
| 953 | for (const { sid, data } of bindings) { |
| 954 | const config = data.notificationConfiguration; |
| 955 | if (!config) continue; |
| 956 | const target = <T extends NotificationTarget>(value: T): T => ({ |
| 957 | ...value, |
| 958 | Id: `${owner}${createHash("sha256") |
| 959 | .update( |
| 960 | JSON.stringify([ |
| 961 | sid, |
| 962 | value.Id ?? notificationTargetKey(value), |
| 963 | ]), |
| 964 | ) |
| 965 | .digest("hex")}`, |
| 966 | }); |
| 967 | desired.LambdaFunctionConfigurations!.push( |
| 968 | ...(config.LambdaFunctionConfigurations ?? []).map(target), |
| 969 | ); |
| 970 | desired.QueueConfigurations!.push( |
| 971 | ...(config.QueueConfigurations ?? []).map(target), |
| 972 | ); |
| 973 | desired.TopicConfigurations!.push( |
| 974 | ...(config.TopicConfigurations ?? []).map(target), |
| 975 | ); |
| 976 | if (config.EventBridgeConfiguration !== undefined) { |
| 977 | desired.EventBridgeConfiguration = {}; |
| 978 | } |
| 979 | } |
| 980 | return desired; |
| 981 | }); |
| 982 | const mergeNotifications = ( |
| 983 | existing: s3.NotificationConfiguration, |
| 984 | desired: s3.NotificationConfiguration, |
| 985 | previous: s3.NotificationConfiguration | undefined, |
| 986 | owner: string, |
| 987 | ownsEventBridge: boolean, |
| 988 | ): s3.NotificationConfiguration => { |
| 989 | const targets = <T extends NotificationTarget>( |
| 990 | observed: T[] = [], |
| 991 | wanted: T[] = [], |
| 992 | managed: T[] = [], |
| 993 | ): T[] => { |
| 994 | const managedIds = new Set( |
| 995 | managed.flatMap((target) => (target.Id ? [target.Id] : [])), |
| 996 | ); |
| 997 | const external = observed.filter( |
| 998 | (target) => |
| 999 | !( |
| 1000 | target.Id?.startsWith(owner) && |
| 1001 | /^[0-9a-f]{64}$/.test(target.Id.slice(owner.length)) |
| 1002 | ) && !managedIds.has(target.Id ?? ""), |
| 1003 | ); |
| 1004 | const satisfied = new Set(external.map(notificationTargetKey)); |
| 1005 | return [ |
| 1006 | ...external, |
| 1007 | ...[...wanted] |
| 1008 | .sort((a, b) => (a.Id ?? "").localeCompare(b.Id ?? "")) |
| 1009 | .filter((target) => { |
| 1010 | const key = notificationTargetKey(target); |
| 1011 | if (satisfied.has(key)) return false; |
| 1012 | satisfied.add(key); |
| 1013 | return true; |
| 1014 | }), |
| 1015 | ]; |
| 1016 | }; |
| 1017 | return { |
| 1018 | LambdaFunctionConfigurations: targets( |
| 1019 | existing.LambdaFunctionConfigurations, |
| 1020 | desired.LambdaFunctionConfigurations, |
| 1021 | previous?.LambdaFunctionConfigurations, |
| 1022 | ), |
| 1023 | QueueConfigurations: targets( |
| 1024 | existing.QueueConfigurations, |
| 1025 | desired.QueueConfigurations, |
| 1026 | previous?.QueueConfigurations, |
| 1027 | ), |
| 1028 | TopicConfigurations: targets( |
| 1029 | existing.TopicConfigurations, |
| 1030 | desired.TopicConfigurations, |
| 1031 | previous?.TopicConfigurations, |
| 1032 | ), |
| 1033 | ...(desired.EventBridgeConfiguration !== undefined || |
| 1034 | (existing.EventBridgeConfiguration !== undefined && !ownsEventBridge) |
| 1035 | ? { EventBridgeConfiguration: {} } |
| 1036 | : {}), |
| 1037 | }; |
| 1038 | }; |
| 1039 | const syncBucketNotifications = Effect.fn(function* ({ |
| 1040 | bucketName, |
| 1041 | bindings, |
| 1042 | previous, |
| 1043 | session, |
| 1044 | operation, |
| 1045 | }: { |
| 1046 | bucketName: string; |
| 1047 | session: ScopedPlanStatusSession; |
| 1048 | bindings: ResourceBinding<Bucket["Binding"]>[]; |
| 1049 | previous: s3.NotificationConfiguration | undefined; |
| 1050 | operation: "create" | "update"; |
| 1051 | }) { |
| 1052 | const owner = yield* notificationOwner(bucketName); |
| 1053 | const desired = yield* desiredNotifications(bindings, owner); |
| 1054 | const existing = yield* s3.getBucketNotificationConfiguration({ |
| 1055 | Bucket: bucketName, |
| 1056 | }); |
| 1057 | const tags = yield* fetchBucketTags(bucketName); |
| 1058 | let ownsEventBridge = tags[eventBridgeOwnerTag] === owner; |
| 1059 | if ( |
| 1060 | desired.EventBridgeConfiguration !== undefined && |
| 1061 | existing.EventBridgeConfiguration === undefined && |
| 1062 | !ownsEventBridge |
| 1063 | ) { |
| 1064 | // Record ownership before enabling so interrupted writes remain recoverable. |
| 1065 | yield* s3.putBucketTagging({ |
| 1066 | Bucket: bucketName, |
| 1067 | Tagging: { |
| 1068 | TagSet: Object.entries({ |
| 1069 | ...tags, |
| 1070 | [eventBridgeOwnerTag]: owner, |
| 1071 | }).map(([Key, Value]) => ({ Key, Value })), |
| 1072 | }, |
| 1073 | }); |
| 1074 | ownsEventBridge = true; |
| 1075 | } |
| 1076 | const next = mergeNotifications( |
| 1077 | existing, |
| 1078 | desired, |
| 1079 | previous, |
| 1080 | owner, |
| 1081 | ownsEventBridge, |
| 1082 | ); |
| 1083 | if (notificationKey(existing) !== notificationKey(next)) { |
| 1084 | yield* Effect.logInfo( |
| 1085 | `S3 Bucket ${operation}: applying notification configuration to ${bucketName}`, |
| 1086 | ); |
| 1087 | yield* s3.putBucketNotificationConfiguration({ |
| 1088 | Bucket: bucketName, |
| 1089 | NotificationConfiguration: next, |
| 1090 | // Destination policies can reconcile later in the same binding cycle. |
| 1091 | SkipDestinationValidation: true, |
| 1092 | }); |
| 1093 | yield* session.note(`Updated bucket notifications: ${bucketName}`); |
| 1094 | } |
| 1095 | if (desired.EventBridgeConfiguration === undefined && ownsEventBridge) { |
| 1096 | delete tags[eventBridgeOwnerTag]; |
| 1097 | if (Object.keys(tags).length > 0) { |
| 1098 | yield* s3.putBucketTagging({ |
| 1099 | Bucket: bucketName, |
| 1100 | Tagging: { |
| 1101 | TagSet: Object.entries(tags).map(([Key, Value]) => ({ |
| 1102 | Key, |
| 1103 | Value, |
| 1104 | })), |
| 1105 | }, |
| 1106 | }); |
| 1107 | } else { |
| 1108 | yield* s3.deleteBucketTagging({ Bucket: bucketName }); |
| 1109 | } |
| 1110 | } |
| 1111 | return desired; |
| 1112 | }); |
| 1113 | |
| 1114 | // ---- Bucket configuration sync helpers ------------------------------ |
| 1115 | // Each helper observes the bucket's live cloud state, computes the |
| 1116 | // desired state from `news`, early-returns on a no-op, and applies only |
| 1117 | // the delta. The "not configured" read error for each aspect is already |
| 1118 | // a typed tag in distilled (see processes/AWS/catalog/S3.md), so we |
| 1119 | // `Effect.catchTag` it rather than inspecting status codes. |
| 1120 | |
| 1121 | const syncBucketVersioning = Effect.fn(function* ({ |
| 1122 | bucketName, |
| 1123 | versioning, |
| 1124 | mfaDelete, |
| 1125 | session, |
| 1126 | }: { |
| 1127 | bucketName: string; |
| 1128 | versioning?: "Enabled" | "Suspended"; |
| 1129 | mfaDelete?: "Enabled" | "Disabled"; |
| 1130 | session: ScopedPlanStatusSession; |
| 1131 | }) { |
| 1132 | if (versioning === undefined && mfaDelete === undefined) return; |
| 1133 | const current = yield* s3.getBucketVersioning({ Bucket: bucketName }); |
| 1134 | const desiredStatus = versioning; |
| 1135 | const desiredMfa = mfaDelete; |
| 1136 | if ( |
| 1137 | (desiredStatus === undefined || current.Status === desiredStatus) && |
| 1138 | (desiredMfa === undefined || current.MFADelete === desiredMfa) |
| 1139 | ) { |
| 1140 | return; |
| 1141 | } |
| 1142 | yield* s3.putBucketVersioning({ |
| 1143 | Bucket: bucketName, |
| 1144 | VersioningConfiguration: { |
| 1145 | Status: desiredStatus, |
| 1146 | MFADelete: desiredMfa, |
| 1147 | }, |
| 1148 | }); |
| 1149 | yield* session.note(`Updated bucket versioning: ${bucketName}`); |
| 1150 | }); |
| 1151 | |
| 1152 | const syncPublicAccessBlock = Effect.fn(function* ({ |
| 1153 | bucketName, |
| 1154 | publicAccessBlock, |
| 1155 | session, |
| 1156 | }: { |
| 1157 | bucketName: string; |
| 1158 | publicAccessBlock?: BucketPublicAccessBlock; |
| 1159 | session: ScopedPlanStatusSession; |
| 1160 | }) { |
| 1161 | if (publicAccessBlock === undefined) return; |
| 1162 | const desired: s3.PublicAccessBlockConfiguration = { |
| 1163 | BlockPublicAcls: publicAccessBlock.blockPublicAcls ?? false, |
| 1164 | IgnorePublicAcls: publicAccessBlock.ignorePublicAcls ?? false, |
| 1165 | BlockPublicPolicy: publicAccessBlock.blockPublicPolicy ?? false, |
| 1166 | RestrictPublicBuckets: |
| 1167 | publicAccessBlock.restrictPublicBuckets ?? false, |
| 1168 | }; |
| 1169 | const current = yield* s3 |
| 1170 | .getPublicAccessBlock({ Bucket: bucketName }) |
| 1171 | .pipe( |
| 1172 | Effect.map((r) => r.PublicAccessBlockConfiguration), |
| 1173 | Effect.catchTag("NoSuchPublicAccessBlockConfiguration", () => |
| 1174 | Effect.succeed<s3.PublicAccessBlockConfiguration | undefined>( |
| 1175 | undefined, |
| 1176 | ), |
| 1177 | ), |
| 1178 | ); |
| 1179 | const canon = (c: s3.PublicAccessBlockConfiguration | undefined) => |
| 1180 | JSON.stringify({ |
| 1181 | a: c?.BlockPublicAcls ?? false, |
| 1182 | i: c?.IgnorePublicAcls ?? false, |
| 1183 | p: c?.BlockPublicPolicy ?? false, |
| 1184 | r: c?.RestrictPublicBuckets ?? false, |
| 1185 | }); |
| 1186 | if (canon(current) === canon(desired)) return; |
| 1187 | yield* s3.putPublicAccessBlock({ |
| 1188 | Bucket: bucketName, |
| 1189 | PublicAccessBlockConfiguration: desired, |
| 1190 | }); |
| 1191 | yield* session.note(`Updated public access block: ${bucketName}`); |
| 1192 | }); |
| 1193 | |
| 1194 | const canonCors = (rules: readonly s3.CORSRule[]) => |
| 1195 | JSON.stringify( |
| 1196 | Arr.map(rules, (r) => ({ |
| 1197 | // Drop S3-assigned `ID`s and sort member arrays so re-ordering |
| 1198 | // doesn't read as drift. |
| 1199 | headers: Arr.sort(r.AllowedHeaders ?? [], Order.String), |
| 1200 | methods: Arr.sort(r.AllowedMethods ?? [], Order.String), |
| 1201 | origins: Arr.sort(r.AllowedOrigins ?? [], Order.String), |
| 1202 | expose: Arr.sort(r.ExposeHeaders ?? [], Order.String), |
| 1203 | maxAge: r.MaxAgeSeconds ?? null, |
| 1204 | })), |
| 1205 | ); |
| 1206 | |
| 1207 | const syncBucketCors = Effect.fn(function* ({ |
| 1208 | bucketName, |
| 1209 | cors, |
| 1210 | session, |
| 1211 | }: { |
| 1212 | bucketName: string; |
| 1213 | cors?: s3.CORSRule[]; |
| 1214 | session: ScopedPlanStatusSession; |
| 1215 | }) { |
| 1216 | if (cors === undefined) return; |
| 1217 | const current = yield* s3.getBucketCors({ Bucket: bucketName }).pipe( |
| 1218 | Effect.map((r) => r.CORSRules ?? []), |
| 1219 | Effect.catchTag("NoSuchCORSConfiguration", () => |
| 1220 | Effect.succeed<s3.CORSRule[]>([]), |
| 1221 | ), |
| 1222 | ); |
| 1223 | if (cors.length === 0) { |
| 1224 | if (current.length === 0) return; |
| 1225 | yield* s3.deleteBucketCors({ Bucket: bucketName }); |
| 1226 | yield* session.note(`Removed bucket CORS: ${bucketName}`); |
| 1227 | return; |
| 1228 | } |
| 1229 | if (canonCors(current) === canonCors(cors)) return; |
| 1230 | yield* s3.putBucketCors({ |
| 1231 | Bucket: bucketName, |
| 1232 | CORSConfiguration: { CORSRules: cors }, |
| 1233 | }); |
| 1234 | yield* session.note(`Updated bucket CORS: ${bucketName}`); |
| 1235 | }); |
| 1236 | |
| 1237 | const canonLifecycle = (rules: readonly s3.LifecycleRule[]) => |
| 1238 | JSON.stringify( |
| 1239 | Arr.sort( |
| 1240 | Arr.map(rules, (r) => ({ ...r })), |
| 1241 | Order.mapInput(Order.String, (r: s3.LifecycleRule) => r.ID ?? ""), |
| 1242 | ), |
| 1243 | ); |
| 1244 | |
| 1245 | const syncBucketLifecycle = Effect.fn(function* ({ |
| 1246 | bucketName, |
| 1247 | lifecycleRules, |
| 1248 | session, |
| 1249 | }: { |
| 1250 | bucketName: string; |
| 1251 | lifecycleRules?: s3.LifecycleRule[]; |
| 1252 | session: ScopedPlanStatusSession; |
| 1253 | }) { |
| 1254 | if (lifecycleRules === undefined) return; |
| 1255 | const current = yield* s3 |
| 1256 | .getBucketLifecycleConfiguration({ Bucket: bucketName }) |
| 1257 | .pipe( |
| 1258 | Effect.map((r) => r.Rules ?? []), |
| 1259 | Effect.catchTag("NoSuchLifecycleConfiguration", () => |
| 1260 | Effect.succeed<s3.LifecycleRule[]>([]), |
| 1261 | ), |
| 1262 | ); |
| 1263 | if (lifecycleRules.length === 0) { |
| 1264 | if (current.length === 0) return; |
| 1265 | yield* s3.deleteBucketLifecycle({ Bucket: bucketName }); |
| 1266 | yield* session.note(`Removed bucket lifecycle: ${bucketName}`); |
| 1267 | return; |
| 1268 | } |
| 1269 | if (canonLifecycle(current) === canonLifecycle(lifecycleRules)) return; |
| 1270 | yield* s3.putBucketLifecycleConfiguration({ |
| 1271 | Bucket: bucketName, |
| 1272 | LifecycleConfiguration: { Rules: lifecycleRules }, |
| 1273 | }); |
| 1274 | yield* session.note(`Updated bucket lifecycle: ${bucketName}`); |
| 1275 | }); |
| 1276 | |
| 1277 | const syncBucketOwnershipControls = Effect.fn(function* ({ |
| 1278 | bucketName, |
| 1279 | objectOwnership, |
| 1280 | session, |
| 1281 | }: { |
| 1282 | bucketName: string; |
| 1283 | objectOwnership?: |
| 1284 | | "BucketOwnerPreferred" |
| 1285 | | "ObjectWriter" |
| 1286 | | "BucketOwnerEnforced"; |
| 1287 | session: ScopedPlanStatusSession; |
| 1288 | }) { |
| 1289 | if (objectOwnership === undefined) return; |
| 1290 | const current = yield* s3 |
| 1291 | .getBucketOwnershipControls({ Bucket: bucketName }) |
| 1292 | .pipe( |
| 1293 | Effect.map((r) => r.OwnershipControls?.Rules?.[0]?.ObjectOwnership), |
| 1294 | Effect.catchTag("OwnershipControlsNotFoundError", () => |
| 1295 | Effect.succeed<string | undefined>(undefined), |
| 1296 | ), |
| 1297 | ); |
| 1298 | if (current === objectOwnership) return; |
| 1299 | yield* s3.putBucketOwnershipControls({ |
| 1300 | Bucket: bucketName, |
| 1301 | OwnershipControls: { |
| 1302 | Rules: [{ ObjectOwnership: objectOwnership }], |
| 1303 | }, |
| 1304 | }); |
| 1305 | yield* session.note(`Updated object ownership: ${bucketName}`); |
| 1306 | }); |
| 1307 | |
| 1308 | const syncBucketAcl = Effect.fn(function* ({ |
| 1309 | bucketName, |
| 1310 | acl, |
| 1311 | session, |
| 1312 | }: { |
| 1313 | bucketName: string; |
| 1314 | acl?: s3.BucketCannedACL; |
| 1315 | session: ScopedPlanStatusSession; |
| 1316 | }) { |
| 1317 | // S3 has no canned-ACL read, so we cannot diff; only apply when set. |
| 1318 | // putBucketAcl is idempotent for canned ACLs. |
| 1319 | if (acl === undefined) return; |
| 1320 | yield* s3.putBucketAcl({ Bucket: bucketName, ACL: acl }); |
| 1321 | yield* session.note(`Updated bucket ACL: ${bucketName}`); |
| 1322 | }); |
| 1323 | |
| 1324 | const syncBucketLogging = Effect.fn(function* ({ |
| 1325 | bucketName, |
| 1326 | logging, |
| 1327 | session, |
| 1328 | }: { |
| 1329 | bucketName: string; |
| 1330 | logging?: BucketLogging; |
| 1331 | session: ScopedPlanStatusSession; |
| 1332 | }) { |
| 1333 | if (logging === undefined) return; |
| 1334 | const current = yield* s3 |
| 1335 | .getBucketLogging({ Bucket: bucketName }) |
| 1336 | .pipe(Effect.map((r) => r.LoggingEnabled)); |
| 1337 | const desired: s3.LoggingEnabled = { |
| 1338 | TargetBucket: logging.targetBucket, |
| 1339 | TargetPrefix: logging.targetPrefix, |
| 1340 | TargetGrants: logging.targetGrants, |
| 1341 | TargetObjectKeyFormat: logging.targetObjectKeyFormat, |
| 1342 | }; |
| 1343 | if ( |
| 1344 | current?.TargetBucket === desired.TargetBucket && |
| 1345 | current?.TargetPrefix === desired.TargetPrefix && |
| 1346 | JSON.stringify(current?.TargetGrants ?? null) === |
| 1347 | JSON.stringify(desired.TargetGrants ?? null) |
| 1348 | ) { |
| 1349 | return; |
| 1350 | } |
| 1351 | yield* s3.putBucketLogging({ |
| 1352 | Bucket: bucketName, |
| 1353 | BucketLoggingStatus: { LoggingEnabled: desired }, |
| 1354 | }); |
| 1355 | yield* session.note(`Updated bucket logging: ${bucketName}`); |
| 1356 | }); |
| 1357 | |
| 1358 | const syncTransferAcceleration = Effect.fn(function* ({ |
| 1359 | bucketName, |
| 1360 | transferAcceleration, |
| 1361 | session, |
| 1362 | }: { |
| 1363 | bucketName: string; |
| 1364 | transferAcceleration?: "Enabled" | "Suspended"; |
| 1365 | session: ScopedPlanStatusSession; |
| 1366 | }) { |
| 1367 | if (transferAcceleration === undefined) return; |
| 1368 | const current = yield* s3 |
| 1369 | .getBucketAccelerateConfiguration({ Bucket: bucketName }) |
| 1370 | .pipe(Effect.map((r) => r.Status)); |
| 1371 | if (current === transferAcceleration) return; |
| 1372 | yield* s3.putBucketAccelerateConfiguration({ |
| 1373 | Bucket: bucketName, |
| 1374 | AccelerateConfiguration: { Status: transferAcceleration }, |
| 1375 | }); |
| 1376 | yield* session.note(`Updated transfer acceleration: ${bucketName}`); |
| 1377 | }); |
| 1378 | |
| 1379 | const syncRequestPayment = Effect.fn(function* ({ |
| 1380 | bucketName, |
| 1381 | requestPayer, |
| 1382 | session, |
| 1383 | }: { |
| 1384 | bucketName: string; |
| 1385 | requestPayer?: "BucketOwner" | "Requester"; |
| 1386 | session: ScopedPlanStatusSession; |
| 1387 | }) { |
| 1388 | if (requestPayer === undefined) return; |
| 1389 | const current = yield* s3 |
| 1390 | .getBucketRequestPayment({ Bucket: bucketName }) |
| 1391 | .pipe(Effect.map((r) => r.Payer)); |
| 1392 | if (current === requestPayer) return; |
| 1393 | yield* s3.putBucketRequestPayment({ |
| 1394 | Bucket: bucketName, |
| 1395 | RequestPaymentConfiguration: { Payer: requestPayer }, |
| 1396 | }); |
| 1397 | yield* session.note(`Updated request payment: ${bucketName}`); |
| 1398 | }); |
| 1399 | |
| 1400 | const syncBucketWebsite = Effect.fn(function* ({ |
| 1401 | bucketName, |
| 1402 | website, |
| 1403 | session, |
| 1404 | }: { |
| 1405 | bucketName: string; |
| 1406 | website?: BucketWebsite; |
| 1407 | session: ScopedPlanStatusSession; |
| 1408 | }) { |
| 1409 | if (website === undefined) return; |
| 1410 | const current = yield* s3.getBucketWebsite({ Bucket: bucketName }).pipe( |
| 1411 | Effect.map((r) => r as s3.GetBucketWebsiteOutput | undefined), |
| 1412 | Effect.catchTag("NoSuchWebsiteConfiguration", () => |
| 1413 | Effect.succeed<s3.GetBucketWebsiteOutput | undefined>(undefined), |
| 1414 | ), |
| 1415 | ); |
| 1416 | const desired: s3.WebsiteConfiguration = { |
| 1417 | IndexDocument: website.indexDocument |
| 1418 | ? { Suffix: website.indexDocument.suffix } |
| 1419 | : undefined, |
| 1420 | ErrorDocument: website.errorDocument |
| 1421 | ? { Key: website.errorDocument.key } |
| 1422 | : undefined, |
| 1423 | RedirectAllRequestsTo: website.redirectAllRequestsTo |
| 1424 | ? { |
| 1425 | HostName: website.redirectAllRequestsTo.hostName, |
| 1426 | Protocol: website.redirectAllRequestsTo.protocol, |
| 1427 | } |
| 1428 | : undefined, |
| 1429 | RoutingRules: website.routingRules, |
| 1430 | }; |
| 1431 | const canon = ( |
| 1432 | w: s3.GetBucketWebsiteOutput | s3.WebsiteConfiguration | undefined, |
| 1433 | ) => |
| 1434 | JSON.stringify({ |
| 1435 | index: w?.IndexDocument ?? null, |
| 1436 | error: w?.ErrorDocument ?? null, |
| 1437 | redirect: w?.RedirectAllRequestsTo ?? null, |
| 1438 | routing: w?.RoutingRules ?? null, |
| 1439 | }); |
| 1440 | if (canon(current) === canon(desired)) return; |
| 1441 | yield* s3.putBucketWebsite({ |
| 1442 | Bucket: bucketName, |
| 1443 | WebsiteConfiguration: desired, |
| 1444 | }); |
| 1445 | yield* session.note(`Updated bucket website: ${bucketName}`); |
| 1446 | }); |
| 1447 | |
| 1448 | const canonReplication = ( |
| 1449 | cfg: |
| 1450 | | { Role?: string; Rules?: readonly s3.ReplicationRule[] } |
| 1451 | | undefined, |
| 1452 | ) => |
| 1453 | JSON.stringify({ |
| 1454 | role: cfg?.Role ?? null, |
| 1455 | rules: Arr.sort( |
| 1456 | Arr.map(cfg?.Rules ?? [], (r) => ({ ...r })), |
| 1457 | Order.mapInput(Order.String, (r: s3.ReplicationRule) => r.ID ?? ""), |
| 1458 | ), |
| 1459 | }); |
| 1460 | |
| 1461 | const syncBucketReplication = Effect.fn(function* ({ |
| 1462 | bucketName, |
| 1463 | replication, |
| 1464 | session, |
| 1465 | }: { |
| 1466 | bucketName: string; |
| 1467 | replication?: BucketReplication; |
| 1468 | session: ScopedPlanStatusSession; |
| 1469 | }) { |
| 1470 | if (replication === undefined) return; |
| 1471 | const current = yield* s3 |
| 1472 | .getBucketReplication({ Bucket: bucketName }) |
| 1473 | .pipe( |
| 1474 | Effect.map((r) => r.ReplicationConfiguration), |
| 1475 | Effect.catchTag("ReplicationConfigurationNotFoundError", () => |
| 1476 | Effect.succeed<s3.ReplicationConfiguration | undefined>( |
| 1477 | undefined, |
| 1478 | ), |
| 1479 | ), |
| 1480 | ); |
| 1481 | const desired: s3.ReplicationConfiguration = { |
| 1482 | Role: replication.role, |
| 1483 | Rules: replication.rules, |
| 1484 | }; |
| 1485 | if (canonReplication(current) === canonReplication(desired)) return; |
| 1486 | yield* s3.putBucketReplication({ |
| 1487 | Bucket: bucketName, |
| 1488 | ReplicationConfiguration: desired, |
| 1489 | }); |
| 1490 | yield* session.note(`Updated bucket replication: ${bucketName}`); |
| 1491 | }); |
| 1492 | |
| 1493 | const syncIntelligentTiering = Effect.fn(function* ({ |
| 1494 | bucketName, |
| 1495 | intelligentTiering, |
| 1496 | oldIntelligentTiering, |
| 1497 | session, |
| 1498 | }: { |
| 1499 | bucketName: string; |
| 1500 | intelligentTiering?: s3.IntelligentTieringConfiguration[]; |
| 1501 | oldIntelligentTiering?: s3.IntelligentTieringConfiguration[]; |
| 1502 | session: ScopedPlanStatusSession; |
| 1503 | }) { |
| 1504 | if (intelligentTiering === undefined) return; |
| 1505 | const desiredById = new Map( |
| 1506 | Arr.map(intelligentTiering, (c) => [c.Id, c] as const), |
| 1507 | ); |
| 1508 | // Reconcile each desired id: put when missing or changed (observed |
| 1509 | // against the per-id read, which is read-after-write consistent). |
| 1510 | for (const [id, desired] of desiredById) { |
| 1511 | const current = yield* s3 |
| 1512 | .getBucketIntelligentTieringConfiguration({ |
| 1513 | Bucket: bucketName, |
| 1514 | Id: id, |
| 1515 | }) |
| 1516 | .pipe( |
| 1517 | Effect.map((r) => r.IntelligentTieringConfiguration), |
| 1518 | Effect.catchTag("NoSuchConfiguration", () => |
| 1519 | Effect.succeed<s3.IntelligentTieringConfiguration | undefined>( |
| 1520 | undefined, |
| 1521 | ), |
| 1522 | ), |
| 1523 | ); |
| 1524 | if (JSON.stringify(current) === JSON.stringify(desired)) continue; |
| 1525 | yield* s3.putBucketIntelligentTieringConfiguration({ |
| 1526 | Bucket: bucketName, |
| 1527 | Id: id, |
| 1528 | IntelligentTieringConfiguration: desired, |
| 1529 | }); |
| 1530 | yield* session.note( |
| 1531 | `Updated intelligent-tiering ${id}: ${bucketName}`, |
| 1532 | ); |
| 1533 | } |
| 1534 | // Remove ids that were previously declared but are no longer desired. |
| 1535 | // `list` is eventually-consistent, so diff against the prior props |
| 1536 | // (when available) and fall back to listing for adoption. Each delete |
| 1537 | // tolerates a config that is already gone. |
| 1538 | const removedIds = new Set<string>(); |
| 1539 | for (const old of oldIntelligentTiering ?? []) { |
| 1540 | if (old.Id && !desiredById.has(old.Id)) removedIds.add(old.Id); |
| 1541 | } |
| 1542 | if (oldIntelligentTiering === undefined) { |
| 1543 | const observed = yield* s3 |
| 1544 | .listBucketIntelligentTieringConfigurations({ Bucket: bucketName }) |
| 1545 | .pipe( |
| 1546 | Effect.map((r) => r.IntelligentTieringConfigurationList ?? []), |
| 1547 | ); |
| 1548 | for (const cfg of observed) { |
| 1549 | if (cfg.Id && !desiredById.has(cfg.Id)) removedIds.add(cfg.Id); |
| 1550 | } |
| 1551 | } |
| 1552 | for (const id of removedIds) { |
| 1553 | // delete is idempotent server-side (no NoSuchConfiguration in the |
| 1554 | // typed error union), so a missing id is simply a no-op. |
| 1555 | yield* s3.deleteBucketIntelligentTieringConfiguration({ |
| 1556 | Bucket: bucketName, |
| 1557 | Id: id, |
| 1558 | }); |
| 1559 | yield* session.note( |
| 1560 | `Removed intelligent-tiering ${id}: ${bucketName}`, |
| 1561 | ); |
| 1562 | } |
| 1563 | }); |
| 1564 | |
| 1565 | const syncObjectLockRetention = Effect.fn(function* ({ |
| 1566 | bucketName, |
| 1567 | objectLockConfiguration, |
| 1568 | session, |
| 1569 | }: { |
| 1570 | bucketName: string; |
| 1571 | objectLockConfiguration?: BucketObjectLockConfiguration; |
| 1572 | session: ScopedPlanStatusSession; |
| 1573 | }) { |
| 1574 | if (objectLockConfiguration === undefined) return; |
| 1575 | const current = yield* s3 |
| 1576 | .getObjectLockConfiguration({ Bucket: bucketName }) |
| 1577 | .pipe( |
| 1578 | Effect.map( |
| 1579 | (r) => r.ObjectLockConfiguration?.Rule?.DefaultRetention, |
| 1580 | ), |
| 1581 | Effect.catchTag("ObjectLockConfigurationNotFoundError", () => |
| 1582 | Effect.succeed<s3.DefaultRetention | undefined>(undefined), |
| 1583 | ), |
| 1584 | ); |
| 1585 | const desired: s3.DefaultRetention = { |
| 1586 | Mode: objectLockConfiguration.mode, |
| 1587 | Days: durationToDays(objectLockConfiguration.days), |
| 1588 | Years: objectLockConfiguration.years, |
| 1589 | }; |
| 1590 | const canon = (r: s3.DefaultRetention | undefined) => |
| 1591 | JSON.stringify({ |
| 1592 | mode: r?.Mode ?? null, |
| 1593 | days: r?.Days ?? null, |
| 1594 | years: r?.Years ?? null, |
| 1595 | }); |
| 1596 | if (canon(current) === canon(desired)) return; |
| 1597 | yield* s3.putObjectLockConfiguration({ |
| 1598 | Bucket: bucketName, |
| 1599 | ObjectLockConfiguration: { |
| 1600 | ObjectLockEnabled: "Enabled", |
| 1601 | Rule: { DefaultRetention: desired }, |
| 1602 | }, |
| 1603 | }); |
| 1604 | yield* session.note(`Updated object-lock retention: ${bucketName}`); |
| 1605 | }); |
| 1606 | |
| 1607 | return { |
| 1608 | stables: ["bucketName", "bucketArn", "region", "accountId"], |
| 1609 | // ListBuckets enumerates this account. Read verifies the same ownership |
| 1610 | // with ExpectedBucketOwner; cross-account access alone is not ownership. |
| 1611 | // GetBucketLocation needs configuration access, not HeadBucket's |
| 1612 | // s3:ListBucket permission to enumerate the bucket's objects. |
| 1613 | list: () => |
| 1614 | Effect.gen(function* () { |
| 1615 | const { accountId, region } = yield* AWSEnvironment.current; |
| 1616 | // S3 only includes `BucketRegion` in the response when the request |
| 1617 | // carries at least one parameter — hence the explicit MaxBuckets. |
| 1618 | return yield* s3.listBuckets.pages({ MaxBuckets: 1000 }).pipe( |
| 1619 | Stream.runCollect, |
| 1620 | Effect.map((chunk) => |
| 1621 | Array.from(chunk).flatMap((page) => |
| 1622 | (page.Buckets ?? []) |
| 1623 | .filter( |
| 1624 | (b): b is s3.Bucket & { Name: string } => b.Name != null, |
| 1625 | ) |
| 1626 | .map((b) => { |
| 1627 | // ListBuckets is global — record each bucket's actual |
| 1628 | // region so later operations (delete) can target it. |
| 1629 | const bucketRegion = (b.BucketRegion ?? |
| 1630 | region) as RegionID; |
| 1631 | return { |
| 1632 | bucketName: b.Name, |
| 1633 | bucketArn: `arn:aws:s3:::${b.Name}` as const, |
| 1634 | bucketDomainName: `${b.Name}.s3.amazonaws.com` as const, |
| 1635 | bucketRegionalDomainName: |
| 1636 | `${b.Name}.s3.${bucketRegion}.amazonaws.com` as const, |
| 1637 | region: bucketRegion, |
| 1638 | accountId, |
| 1639 | }; |
| 1640 | }), |
| 1641 | ), |
| 1642 | ), |
| 1643 | ); |
| 1644 | }), |
| 1645 | read: Effect.fn(function* ({ id, fqn, instanceId, olds, output }) { |
| 1646 | const bucketName = |
| 1647 | output?.bucketName ?? (yield* createBucketName(id, olds ?? {})); |
| 1648 | const { accountId, region } = yield* AWSEnvironment.current; |
| 1649 | const exists = yield* s3 |
| 1650 | .getBucketLocation({ |
| 1651 | Bucket: bucketName, |
| 1652 | ExpectedBucketOwner: accountId, |
| 1653 | }) |
| 1654 | .pipe( |
| 1655 | Effect.map(() => true), |
| 1656 | Effect.catchTag("NoSuchBucket", () => Effect.succeed(false)), |
| 1657 | ); |
| 1658 | if (!exists) return undefined; |
| 1659 | return { |
| 1660 | bucketName, |
| 1661 | bucketArn: `arn:aws:s3:::${bucketName}` as const, |
| 1662 | bucketDomainName: `${bucketName}.s3.amazonaws.com` as const, |
| 1663 | bucketRegionalDomainName: |
| 1664 | `${bucketName}.s3.${region}.amazonaws.com` as const, |
| 1665 | region, |
| 1666 | accountId, |
| 1667 | managedNotificationConfiguration: yield* readManagedNotifications( |
| 1668 | fqn, |
| 1669 | instanceId, |
| 1670 | output, |
| 1671 | ), |
| 1672 | }; |
| 1673 | }), |
| 1674 | diff: Effect.fn(function* ({ |
| 1675 | id, |
| 1676 | fqn, |
| 1677 | instanceId, |
| 1678 | news = {}, |
| 1679 | olds = {}, |
| 1680 | output, |
| 1681 | newBindings, |
| 1682 | }) { |
| 1683 | if (!isResolved(news)) return undefined; |
| 1684 | const oldBucketName = yield* createBucketName(id, olds); |
| 1685 | const newBucketName = yield* createBucketName(id, news); |
| 1686 | yield* Effect.logInfo( |
| 1687 | `S3 Bucket diff: old=${oldBucketName} new=${newBucketName} oldObjectLock=${olds.objectLockEnabled ?? false} newObjectLock=${news.objectLockEnabled ?? false}`, |
| 1688 | ); |
| 1689 | if (oldBucketName !== newBucketName) { |
| 1690 | yield* Effect.logInfo( |
| 1691 | `S3 Bucket diff: replacing bucket because name changed from ${oldBucketName} to ${newBucketName}`, |
| 1692 | ); |
| 1693 | return { action: "replace" } as const; |
| 1694 | } |
| 1695 | // Object lock can only be enabled at creation time |
| 1696 | if ( |
| 1697 | (olds.objectLockEnabled ?? false) !== |
| 1698 | (news.objectLockEnabled ?? false) |
| 1699 | ) { |
| 1700 | yield* Effect.logInfo( |
| 1701 | `S3 Bucket diff: replacing bucket because object lock changed for ${newBucketName}`, |
| 1702 | ); |
| 1703 | return { action: "replace" } as const; |
| 1704 | } |
| 1705 | if (output) { |
| 1706 | const observed = yield* readBucketEncryption( |
| 1707 | output.bucketName, |
| 1708 | ).pipe( |
| 1709 | Effect.catchTag("NoSuchBucket", () => Effect.succeed(undefined)), |
| 1710 | ); |
| 1711 | if ( |
| 1712 | encryptionFingerprint(observed) !== |
| 1713 | encryptionFingerprint(desiredEncryptionRule(news.encryption)) |
| 1714 | ) { |
| 1715 | return { action: "update" } as const; |
| 1716 | } |
| 1717 | const previous = yield* readManagedNotifications( |
| 1718 | fqn, |
| 1719 | instanceId, |
| 1720 | output, |
| 1721 | ); |
| 1722 | if ( |
| 1723 | output.managedNotificationConfiguration === undefined && |
| 1724 | previous !== undefined |
| 1725 | ) { |
| 1726 | return { action: "update" } as const; |
| 1727 | } |
| 1728 | const owner = yield* notificationOwner(output.bucketName); |
| 1729 | const desired = isResolved(newBindings) |
| 1730 | ? yield* desiredNotifications(newBindings, owner) |
| 1731 | : previous; |
| 1732 | if (desired) { |
| 1733 | const notifications = yield* s3 |
| 1734 | .getBucketNotificationConfiguration({ |
| 1735 | Bucket: output.bucketName, |
| 1736 | }) |
| 1737 | .pipe( |
| 1738 | Effect.catchTag("NoSuchBucket", () => |
| 1739 | Effect.succeed(undefined), |
| 1740 | ), |
| 1741 | ); |
| 1742 | const tags: Record<string, string> = notifications |
| 1743 | ? yield* fetchBucketTags(output.bucketName) |
| 1744 | : {}; |
| 1745 | if ( |
| 1746 | notifications && |
| 1747 | ((desired.EventBridgeConfiguration === undefined && |
| 1748 | tags[eventBridgeOwnerTag] === owner) || |
| 1749 | notificationKey(notifications) !== |
| 1750 | notificationKey( |
| 1751 | mergeNotifications( |
| 1752 | notifications, |
| 1753 | desired, |
| 1754 | previous, |
| 1755 | owner, |
| 1756 | tags[eventBridgeOwnerTag] === owner, |
| 1757 | ), |
| 1758 | )) |
| 1759 | ) { |
| 1760 | return { action: "update" } as const; |
| 1761 | } |
| 1762 | } |
| 1763 | } |
| 1764 | }), |
| 1765 | precreate: (props) => ensureBucketExists(props), |
| 1766 | reconcile: Effect.fn(function* ({ |
| 1767 | id, |
| 1768 | fqn, |
| 1769 | instanceId, |
| 1770 | news = {}, |
| 1771 | olds, |
| 1772 | output, |
| 1773 | session, |
| 1774 | bindings, |
| 1775 | }) { |
| 1776 | const operation = output === undefined ? "create" : "update"; |
| 1777 | const resolved = output ?? (yield* ensureBucketExists({ id, news })); |
| 1778 | |
| 1779 | yield* syncBucketTags({ |
| 1780 | bucketName: resolved.bucketName, |
| 1781 | // Omit `oldTags` so syncBucketTags fetches the cloud's actual |
| 1782 | // current tags. This makes drift detection correct even after |
| 1783 | // a cold-start adoption where olds.tags would equal news.tags. |
| 1784 | newTags: news.tags, |
| 1785 | session, |
| 1786 | operation, |
| 1787 | }); |
| 1788 | |
| 1789 | // Ownership + public-access-block must precede any ACL/policy that |
| 1790 | // grants public access, else those puts can fail with AccessDenied. |
| 1791 | yield* syncBucketOwnershipControls({ |
| 1792 | bucketName: resolved.bucketName, |
| 1793 | objectOwnership: news.objectOwnership, |
| 1794 | session, |
| 1795 | }); |
| 1796 | |
| 1797 | yield* syncPublicAccessBlock({ |
| 1798 | bucketName: resolved.bucketName, |
| 1799 | publicAccessBlock: news.publicAccessBlock, |
| 1800 | session, |
| 1801 | }); |
| 1802 | |
| 1803 | // Versioning before replication (replication requires it enabled). |
| 1804 | yield* syncBucketVersioning({ |
| 1805 | bucketName: resolved.bucketName, |
| 1806 | versioning: news.versioning, |
| 1807 | mfaDelete: news.mfaDelete, |
| 1808 | session, |
| 1809 | }); |
| 1810 | |
| 1811 | if ( |
| 1812 | yield* syncBucketEncryption(resolved.bucketName, news.encryption) |
| 1813 | ) { |
| 1814 | yield* session.note( |
| 1815 | `Updated bucket encryption: ${resolved.bucketName}`, |
| 1816 | ); |
| 1817 | } |
| 1818 | |
| 1819 | yield* syncBucketCors({ |
| 1820 | bucketName: resolved.bucketName, |
| 1821 | cors: news.cors, |
| 1822 | session, |
| 1823 | }); |
| 1824 | |
| 1825 | yield* syncBucketLifecycle({ |
| 1826 | bucketName: resolved.bucketName, |
| 1827 | lifecycleRules: news.lifecycleRules, |
| 1828 | session, |
| 1829 | }); |
| 1830 | |
| 1831 | yield* syncBucketLogging({ |
| 1832 | bucketName: resolved.bucketName, |
| 1833 | logging: news.logging, |
| 1834 | session, |
| 1835 | }); |
| 1836 | |
| 1837 | yield* syncTransferAcceleration({ |
| 1838 | bucketName: resolved.bucketName, |
| 1839 | transferAcceleration: news.transferAcceleration, |
| 1840 | session, |
| 1841 | }); |
| 1842 | |
| 1843 | yield* syncRequestPayment({ |
| 1844 | bucketName: resolved.bucketName, |
| 1845 | requestPayer: news.requestPayer, |
| 1846 | session, |
| 1847 | }); |
| 1848 | |
| 1849 | yield* syncBucketWebsite({ |
| 1850 | bucketName: resolved.bucketName, |
| 1851 | website: news.website, |
| 1852 | session, |
| 1853 | }); |
| 1854 | |
| 1855 | yield* syncBucketReplication({ |
| 1856 | bucketName: resolved.bucketName, |
| 1857 | replication: news.replication, |
| 1858 | session, |
| 1859 | }); |
| 1860 | |
| 1861 | yield* syncIntelligentTiering({ |
| 1862 | bucketName: resolved.bucketName, |
| 1863 | intelligentTiering: news.intelligentTiering, |
| 1864 | oldIntelligentTiering: olds?.intelligentTiering, |
| 1865 | session, |
| 1866 | }); |
| 1867 | |
| 1868 | yield* syncObjectLockRetention({ |
| 1869 | bucketName: resolved.bucketName, |
| 1870 | objectLockConfiguration: news.objectLockConfiguration, |
| 1871 | session, |
| 1872 | }); |
| 1873 | |
| 1874 | // ACL after ownership/public-access-block. |
| 1875 | yield* syncBucketAcl({ |
| 1876 | bucketName: resolved.bucketName, |
| 1877 | acl: news.acl, |
| 1878 | session, |
| 1879 | }); |
| 1880 | |
| 1881 | yield* syncBucketPolicy({ |
| 1882 | bucketName: resolved.bucketName, |
| 1883 | bindings, |
| 1884 | explicitStatements: news.policy, |
| 1885 | session, |
| 1886 | operation, |
| 1887 | }); |
| 1888 | |
| 1889 | const managedNotificationConfiguration = |
| 1890 | yield* syncBucketNotifications({ |
| 1891 | bucketName: resolved.bucketName, |
| 1892 | bindings, |
| 1893 | previous: yield* readManagedNotifications( |
| 1894 | fqn, |
| 1895 | instanceId, |
| 1896 | output, |
| 1897 | ), |
| 1898 | session, |
| 1899 | operation, |
| 1900 | }); |
| 1901 | |
| 1902 | if (operation === "create") { |
| 1903 | yield* session.note(`Ensured bucket: ${resolved.bucketName}`); |
| 1904 | } |
| 1905 | |
| 1906 | return { ...resolved, managedNotificationConfiguration }; |
| 1907 | }), |
| 1908 | delete: Effect.fn(function* ({ olds = {}, output, session, force }) { |
| 1909 | // Whether we are allowed to destroy the bucket's contents. |
| 1910 | // - `olds.forceDestroy` — the user opted in on the resource props. |
| 1911 | // - `force` — set only by `alchemy unsafe nuke`, an explicit |
| 1912 | // operator-confirmed account teardown. Nuke enumerates buckets |
| 1913 | // straight from the cloud (its `olds` is Attributes, not Props), |
| 1914 | // so `forceDestroy` is never present there. S3 ownership is |
| 1915 | // account-level: list enumerates this account and read verifies |
| 1916 | // ExpectedBucketOwner. This provider deliberately does not stamp |
| 1917 | // alchemy tags on buckets, so every bucket nuke hands us is one |
| 1918 | // this account owns. |
| 1919 | // A normal destroy without `forceDestroy` must NOT empty the |
| 1920 | // bucket — a non-empty bucket fails with BucketNotEmpty, which is |
| 1921 | // the data-protection behavior users rely on. |
| 1922 | const mayEmpty = olds.forceDestroy === true || force === true; |
| 1923 | yield* Effect.logInfo( |
| 1924 | `S3 Bucket delete: bucket=${output.bucketName} forceDestroy=${olds.forceDestroy ?? false} force=${force ?? false} region=${output.region}`, |
| 1925 | ); |
| 1926 | const run = Effect.gen(function* () { |
| 1927 | // If we may destroy contents, delete all object versions and |
| 1928 | // delete markers first. The bucket may already be gone (deleted |
| 1929 | // out-of-band, or a previous destroy partially succeeded) — |
| 1930 | // treat NoSuchBucket as a no-op so the overall delete still |
| 1931 | // converges. |
| 1932 | if (mayEmpty) { |
| 1933 | yield* session.note( |
| 1934 | `Force destroying bucket: ${output.bucketName} - deleting all objects...`, |
| 1935 | ); |
| 1936 | yield* deleteAllObjects(output.bucketName).pipe( |
| 1937 | Effect.catchTag("NoSuchBucket", () => Effect.void), |
| 1938 | ); |
| 1939 | } |
| 1940 | |
| 1941 | yield* s3 |
| 1942 | .deleteBucket({ |
| 1943 | Bucket: output.bucketName, |
| 1944 | }) |
| 1945 | .pipe( |
| 1946 | Effect.catchTag("NoSuchBucket", () => Effect.void), |
| 1947 | // BucketNotEmpty after an empty is eventual consistency (or |
| 1948 | // a concurrent writer landing new versions between the empty |
| 1949 | // and the DeleteBucket): re-empty so the bounded retry below |
| 1950 | // has a chance to succeed instead of spinning on a bucket |
| 1951 | // that still has content. |
| 1952 | Effect.catchTag("BucketNotEmpty", (e) => |
| 1953 | Effect.gen(function* () { |
| 1954 | if (!mayEmpty) return yield* Effect.fail(e); |
| 1955 | yield* deleteAllObjects(output.bucketName).pipe( |
| 1956 | Effect.catchTag("NoSuchBucket", () => Effect.void), |
| 1957 | ); |
| 1958 | // Re-fail with the original error so the retry policy |
| 1959 | // drives the next DeleteBucket attempt. |
| 1960 | return yield* Effect.fail(e); |
| 1961 | }), |
| 1962 | ), |
| 1963 | Effect.retry({ |
| 1964 | // BucketHasAccessPointsAttached: S3's access-point |
| 1965 | // attachment view is eventually consistent — deleting a |
| 1966 | // bucket immediately after its access points were deleted |
| 1967 | // can transiently report attachments. Retry until the view |
| 1968 | // converges (bounded). |
| 1969 | while: (e): boolean => |
| 1970 | e._tag === "BucketNotEmpty" || |
| 1971 | e._tag === "BucketHasAccessPointsAttached", |
| 1972 | schedule: Schedule.max([ |
| 1973 | Schedule.exponential(250), |
| 1974 | Schedule.recurs(7), |
| 1975 | ]), |
| 1976 | }), |
| 1977 | ); |
| 1978 | }); |
| 1979 | |
| 1980 | // The bucket may live in a different region than the ambient client |
| 1981 | // (list enumerates buckets from every region) — target the bucket's |
| 1982 | // own region to avoid PermanentRedirect. If we still get redirected |
| 1983 | // (e.g. a stale/missing region attribute), retry once in the region |
| 1984 | // the redirect reports. |
| 1985 | yield* ( |
| 1986 | output.region |
| 1987 | ? run.pipe( |
| 1988 | Effect.provideService(Region, Effect.succeed(output.region)), |
| 1989 | ) |
| 1990 | : run |
| 1991 | ).pipe( |
| 1992 | Effect.tapError(Effect.logInfo), |
| 1993 | Effect.catchTag("PermanentRedirect", (e) => |
| 1994 | e.BucketRegion |
| 1995 | ? run.pipe( |
| 1996 | Effect.provideService( |
| 1997 | Region, |
| 1998 | Effect.succeed(e.BucketRegion as RegionID), |
| 1999 | ), |
| 2000 | ) |
| 2001 | : Effect.fail(e), |
| 2002 | ), |
| 2003 | ); |
| 2004 | |
| 2005 | yield* session.note(`Deleted bucket: ${output.bucketName}`); |
| 2006 | }), |
| 2007 | }; |
| 2008 | }), |
| 2009 | ); |
| 2010 | |
| 2011 | class BucketEncryptionNotConverged extends Data.TaggedError( |
| 2012 | "BucketEncryptionNotConverged", |
| 2013 | )<{ bucket: string }> {} |
| 2014 | |
| 2015 | const desiredEncryptionRule = ( |
| 2016 | encryption?: BucketEncryption, |
| 2017 | ): s3.ServerSideEncryptionRule => { |
| 2018 | const algorithm = encryption?.sseAlgorithm ?? "AES256"; |
| 2019 | const blocked = encryption?.blockedEncryptionTypes ?? []; |
| 2020 | return { |
| 2021 | ApplyServerSideEncryptionByDefault: { |
| 2022 | SSEAlgorithm: algorithm, |
| 2023 | KMSMasterKeyID: |
| 2024 | algorithm === "AES256" ? undefined : encryption?.kmsMasterKeyId, |
| 2025 | }, |
| 2026 | BucketKeyEnabled: encryption?.bucketKeyEnabled ?? false, |
| 2027 | BlockedEncryptionTypes: { |
| 2028 | EncryptionType: blocked.length ? [...new Set(blocked)] : ["NONE"], |
| 2029 | }, |
| 2030 | }; |
| 2031 | }; |
| 2032 | |
| 2033 | const encryptionFingerprint = ( |
| 2034 | rule: s3.ServerSideEncryptionRule | undefined, |
| 2035 | ) => { |
| 2036 | const key = rule?.ApplyServerSideEncryptionByDefault?.KMSMasterKeyID; |
| 2037 | return JSON.stringify({ |
| 2038 | algorithm: rule?.ApplyServerSideEncryptionByDefault?.SSEAlgorithm ?? null, |
| 2039 | key: (Redacted.isRedacted(key) ? Redacted.value(key) : key) ?? null, |
| 2040 | bucketKey: rule?.BucketKeyEnabled ?? false, |
| 2041 | blocked: [ |
| 2042 | ...new Set( |
| 2043 | rule?.BlockedEncryptionTypes?.EncryptionType?.filter( |
| 2044 | (type) => type !== "NONE", |
| 2045 | ) ?? [], |
| 2046 | ), |
| 2047 | ].sort(), |
| 2048 | }); |
| 2049 | }; |
| 2050 | |
| 2051 | const readBucketEncryption = (bucket: string) => |
| 2052 | s3 |
| 2053 | .getBucketEncryption({ Bucket: bucket }) |
| 2054 | .pipe( |
| 2055 | Effect.map( |
| 2056 | (result) => result.ServerSideEncryptionConfiguration?.Rules?.[0], |
| 2057 | ), |
| 2058 | ); |
| 2059 | |
| 2060 | export const syncBucketEncryption = Effect.fn(function* ( |
| 2061 | bucket: string, |
| 2062 | encryption?: BucketEncryption, |
| 2063 | ) { |
| 2064 | const desired = desiredEncryptionRule(encryption); |
| 2065 | const matches = (rule: s3.ServerSideEncryptionRule | undefined) => |
| 2066 | encryptionFingerprint(rule) === encryptionFingerprint(desired); |
| 2067 | if (matches(yield* readBucketEncryption(bucket))) return false; |
| 2068 | yield* s3.putBucketEncryption({ |
| 2069 | Bucket: bucket, |
| 2070 | ServerSideEncryptionConfiguration: { Rules: [desired] }, |
| 2071 | }); |
| 2072 | const observed = yield* readBucketEncryption(bucket).pipe( |
| 2073 | Effect.repeat({ |
| 2074 | until: matches, |
| 2075 | schedule: Schedule.spaced("1 second"), |
| 2076 | times: 8, |
| 2077 | }), |
| 2078 | ); |
| 2079 | if (!matches(observed)) { |
| 2080 | return yield* Effect.fail(new BucketEncryptionNotConverged({ bucket })); |
| 2081 | } |
| 2082 | return true; |
| 2083 | }); |