1import { Region } from "@distilled.cloud/aws/Region";
2import type { BucketLocationConstraint } from "@distilled.cloud/aws/s3";
3import * as s3 from "@distilled.cloud/aws/s3";
4import * as Arr from "effect/Array";
5import * as Data from "effect/Data";
6import type * as Duration from "effect/Duration";
7import * as Effect from "effect/Effect";
8import * as Order from "effect/Order";
9import * as Redacted from "effect/Redacted";
10import * as Schedule from "effect/Schedule";
11import * as Stream from "effect/Stream";
12import { createHash } from "node:crypto";
13import type { ScopedPlanStatusSession } from "../../Report.ts";
14import { isResolved } from "../../Diff.ts";
15import { createPhysicalName } from "../../PhysicalName.ts";
16import * as Provider from "../../Provider.ts";
17import { Resource, type ResourceBinding } from "../../Resource.ts";
18import { Stack } from "../../Stack.ts";
19import { isActionState, State } from "../../State/State.ts";
20import { diffTags } from "../../Tags.ts";
21import { AWSEnvironment, type AccountID } from "../Environment.ts";
22import { durationToDays } from "../IAM/common.ts";
23import type { PolicyStatement } from "../IAM/Policy.ts";
24import type { Providers } from "../Providers.ts";
25import type { RegionID } from "../Region.ts";
26
27export type BucketName = string;
28export type BucketArn = `arn:aws:s3:::${BucketName}`;
29
30/**
31 * Server-side encryption configuration for a bucket.
32 */
33export 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 */
63export 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 */
77export 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 */
91export 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 */
107export 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 */
118export 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
131export 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
229export 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 */
487export const Bucket = Resource<Bucket>("AWS.S3.Bucket");
488
489export 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
2011class BucketEncryptionNotConverged extends Data.TaggedError(
2012 "BucketEncryptionNotConverged",
2013)<{ bucket: string }> {}
2014
2015const 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
2033const 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
2051const readBucketEncryption = (bucket: string) =>
2052 s3
2053 .getBucketEncryption({ Bucket: bucket })
2054 .pipe(
2055 Effect.map(
2056 (result) => result.ServerSideEncryptionConfiguration?.Rules?.[0],
2057 ),
2058 );
2059
2060export 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});