1import { Region } from "@distilled.cloud/aws/Region";
2import * as s3control from "@distilled.cloud/aws/s3-control";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { AWSEnvironment, type AccountID } from "../Environment.ts";
12import type { Providers } from "../Providers.ts";
13
14export interface MultiRegionAccessPointProps {
15 /**
16 * Name of the Multi-Region Access Point (3-50 characters, lowercase
17 * letters, numbers and hyphens). If omitted, a unique name is generated
18 * from the app, stage and logical ID.
19 *
20 * Changing the name replaces the Multi-Region Access Point.
21 * @default ${app}-${stage}-${id}
22 */
23 multiRegionAccessPointName?: string;
24 /**
25 * The buckets (at most one per region, up to 20 regions) the
26 * Multi-Region Access Point routes requests to.
27 *
28 * Changing the regions replaces the Multi-Region Access Point.
29 */
30 regions: {
31 /** Name of the bucket in one of the member regions. */
32 bucket: string;
33 /** Account ID that owns the bucket, for cross-account buckets. */
34 bucketAccountId?: string;
35 }[];
36 /**
37 * Block-public-access settings. AWS defaults every flag to `true` for
38 * Multi-Region Access Points when omitted.
39 *
40 * Changing these settings replaces the Multi-Region Access Point.
41 */
42 publicAccessBlock?: {
43 /** Block new public ACLs and uploading public objects. @default true */
44 blockPublicAcls?: boolean;
45 /** Ignore all public ACLs. @default true */
46 ignorePublicAcls?: boolean;
47 /** Block new policies that grant public access. @default true */
48 blockPublicPolicy?: boolean;
49 /** Restrict public policies to AWS principals. @default true */
50 restrictPublicBuckets?: boolean;
51 };
52}
53
54export interface MultiRegionAccessPoint extends Resource<
55 "AWS.S3Control.MultiRegionAccessPoint",
56 MultiRegionAccessPointProps,
57 {
58 /**
59 * Name of the Multi-Region Access Point.
60 */
61 multiRegionAccessPointName: string;
62 /**
63 * ARN of the Multi-Region Access Point (regionless, alias-addressed).
64 */
65 multiRegionAccessPointArn: string;
66 /**
67 * The S3-assigned alias of the Multi-Region Access Point. Requests are
68 * addressed to `${alias}.accesspoint.s3-global.amazonaws.com`.
69 */
70 alias: string | undefined;
71 /**
72 * Provisioning status of the Multi-Region Access Point at the time of
73 * the last deploy (`READY` once fully provisioned).
74 */
75 status: s3control.MultiRegionAccessPointStatus | undefined;
76 /**
77 * AWS account ID that owns the Multi-Region Access Point.
78 */
79 accountId: AccountID;
80 },
81 never,
82 Providers
83> {}
84
85/**
86 * An Amazon S3 Multi-Region Access Point — a single global endpoint that
87 * routes requests to buckets in multiple regions over the AWS global
88 * network.
89 *
90 * Provisioning is asynchronous and slow (several minutes); the provider
91 * submits the request and waits until the access point reaches `READY`.
92 * All control-plane requests are routed through `us-west-2`, as required
93 * by the Multi-Region Access Point API.
94 * ### Creating Multi-Region Access Points
95 * **Example:** Route between two regional buckets
96 * ```typescript
97 * import * as S3Control from "alchemy/AWS/S3Control";
98 *
99 * const mrap = yield* S3Control.MultiRegionAccessPoint("global", {
100 * regions: [
101 * { bucket: usWestBucket.bucketName },
102 * { bucket: euCentralBucket.bucketName },
103 * ],
104 * });
105 * ```
106 *
107 * **Example:** Single-region Multi-Region Access Point
108 * ```typescript
109 * const mrap = yield* S3Control.MultiRegionAccessPoint("global", {
110 * regions: [{ bucket: bucket.bucketName }],
111 * });
112 * ```
113 *
114 * @resource
115 */
116export const MultiRegionAccessPoint = Resource<MultiRegionAccessPoint>(
117 "AWS.S3Control.MultiRegionAccessPoint",
118);
119
120/**
121 * Raised while a Multi-Region Access Point is still provisioning — retried
122 * by {@link retryWhileMrapTransitions} until it reaches `READY`.
123 */
124export class MultiRegionAccessPointNotReady extends Data.TaggedError(
125 "MultiRegionAccessPointNotReady",
126)<{ name: string; status: string | undefined }> {}
127
128/**
129 * Raised while a Multi-Region Access Point deletion is still in flight —
130 * retried by {@link retryWhileMrapTransitions} until the report disappears.
131 */
132export class MultiRegionAccessPointNotDeleted extends Data.TaggedError(
133 "MultiRegionAccessPointNotDeleted",
134)<{ name: string; status: string | undefined }> {}
135
136/**
137 * Multi-Region Access Point provisioning/deletion legitimately takes
138 * several minutes — poll every 15 seconds for up to ~20 minutes.
139 *
140 * Explicitly typed at module scope — inlining `Effect.retry` in a lifecycle
141 * op leaks `Retry.Return`'s conditional type into declaration emit, widening
142 * the provider layer to `unknown` and poisoning `AWS.providers()`.
143 */
144const retryWhileMrapTransitions = <A, E extends { _tag: string }, R>(
145 self: Effect.Effect<A, E, R>,
146): Effect.Effect<A, E, R> =>
147 Effect.retry(self, {
148 while: (e) =>
149 e._tag === "MultiRegionAccessPointNotReady" ||
150 e._tag === "MultiRegionAccessPointNotDeleted",
151 schedule: Schedule.max([Schedule.fixed("15 seconds"), Schedule.recurs(80)]),
152 });
153
154/**
155 * All Multi-Region Access Point control-plane requests are routed to the
156 * US West (Oregon) region regardless of the ambient region.
157 */
158const inMrapRegion = <A, E, R>(
159 self: Effect.Effect<A, E, R>,
160): Effect.Effect<A, E, Exclude<R, Region>> =>
161 self.pipe(Effect.provideService(Region, Effect.succeed("us-west-2")));
162
163export const MultiRegionAccessPointProvider = () =>
164 Provider.effect(
165 MultiRegionAccessPoint,
166 Effect.gen(function* () {
167 const createName = Effect.fn(function* (
168 id: string,
169 props: Pick<MultiRegionAccessPointProps, "multiRegionAccessPointName">,
170 ) {
171 // Multi-Region Access Point names: 3-50 chars, lowercase.
172 return (
173 props.multiRegionAccessPointName ??
174 (yield* createPhysicalName({ id, maxLength: 50, lowercase: true }))
175 );
176 });
177
178 const mrapArn = (accountId: string, alias: string | undefined) =>
179 `arn:aws:s3::${accountId}:accesspoint/${alias ?? ""}`;
180
181 const observeMrap = (accountId: string, name: string) =>
182 inMrapRegion(
183 s3control
184 .getMultiRegionAccessPoint({ AccountId: accountId, Name: name })
185 .pipe(
186 Effect.map((r) => r.AccessPoint),
187 Effect.catchTag("NoSuchMultiRegionAccessPoint", () =>
188 Effect.succeed(undefined),
189 ),
190 ),
191 );
192
193 const toAttrs = (
194 name: string,
195 accountId: AccountID,
196 report: s3control.MultiRegionAccessPointReport | undefined,
197 ) => ({
198 multiRegionAccessPointName: name,
199 multiRegionAccessPointArn: mrapArn(accountId, report?.Alias),
200 alias: report?.Alias,
201 status: report?.Status,
202 accountId,
203 });
204
205 // Wait until the Multi-Region Access Point reaches READY. A report
206 // that is briefly missing right after the async create request is
207 // treated the same as a transitioning one.
208 const awaitReady = (accountId: string, name: string) =>
209 retryWhileMrapTransitions(
210 observeMrap(accountId, name).pipe(
211 Effect.flatMap((report) =>
212 report?.Status === "READY"
213 ? Effect.succeed(report)
214 : Effect.fail(
215 new MultiRegionAccessPointNotReady({
216 name,
217 status: report?.Status,
218 }),
219 ),
220 ),
221 ),
222 );
223
224 const awaitGone = (accountId: string, name: string) =>
225 retryWhileMrapTransitions(
226 observeMrap(accountId, name).pipe(
227 Effect.flatMap((report) =>
228 report === undefined
229 ? Effect.void
230 : Effect.fail(
231 new MultiRegionAccessPointNotDeleted({
232 name,
233 status: report.Status,
234 }),
235 ),
236 ),
237 ),
238 );
239
240 const canonProps = (props: MultiRegionAccessPointProps) =>
241 JSON.stringify({
242 regions: [...props.regions]
243 .map((r) => ({
244 bucket: r.bucket,
245 account: r.bucketAccountId ?? null,
246 }))
247 .sort((a, b) => a.bucket.localeCompare(b.bucket)),
248 pab: {
249 a: props.publicAccessBlock?.blockPublicAcls ?? true,
250 i: props.publicAccessBlock?.ignorePublicAcls ?? true,
251 p: props.publicAccessBlock?.blockPublicPolicy ?? true,
252 r: props.publicAccessBlock?.restrictPublicBuckets ?? true,
253 },
254 });
255
256 return MultiRegionAccessPoint.Provider.of({
257 stables: [
258 "multiRegionAccessPointName",
259 "multiRegionAccessPointArn",
260 "alias",
261 "accountId",
262 ],
263 // Multi-Region Access Points do not support tags; the deterministic
264 // name is the ownership scope.
265 list: () =>
266 Effect.gen(function* () {
267 const { accountId } = yield* AWSEnvironment.current;
268 const pages = yield* inMrapRegion(
269 s3control.listMultiRegionAccessPoints
270 .pages({ AccountId: accountId })
271 .pipe(Stream.runCollect),
272 );
273 return Array.from(pages).flatMap((page) =>
274 (page.AccessPoints ?? []).flatMap((report) =>
275 report.Name !== undefined
276 ? [toAttrs(report.Name, accountId, report)]
277 : [],
278 ),
279 );
280 }),
281 read: Effect.fn(function* ({ id, olds, output }) {
282 const { accountId } = yield* AWSEnvironment.current;
283 const name =
284 output?.multiRegionAccessPointName ??
285 (yield* createName(id, olds ?? {}));
286 const report = yield* observeMrap(accountId, name);
287 if (report === undefined) return undefined;
288 return toAttrs(name, accountId, report);
289 }),
290 diff: Effect.fn(function* ({ id, news, olds }) {
291 if (!isResolved(news)) return undefined;
292 const oldName = yield* createName(id, olds ?? {});
293 const newName = yield* createName(id, news);
294 if (
295 oldName !== newName ||
296 (olds !== undefined && canonProps(olds) !== canonProps(news))
297 ) {
298 // Every property of a Multi-Region Access Point is create-only.
299 return { action: "replace" } as const;
300 }
301 }),
302 reconcile: Effect.fn(function* ({ id, news, output, session }) {
303 const { accountId } = yield* AWSEnvironment.current;
304 const name =
305 output?.multiRegionAccessPointName ?? (yield* createName(id, news));
306
307 // 1. OBSERVE — cloud state is authoritative.
308 const report = yield* observeMrap(accountId, name);
309
310 // 2. ENSURE — submit the async create when missing. The client
311 // token only needs to be unique per submission; idempotency
312 // across crashed runs comes from observing before creating.
313 if (report === undefined) {
314 const clientToken = yield* Effect.sync(() => crypto.randomUUID());
315 yield* session.note(
316 `Creating Multi-Region Access Point ${name} (takes several minutes)...`,
317 );
318 yield* inMrapRegion(
319 s3control.createMultiRegionAccessPoint({
320 AccountId: accountId,
321 ClientToken: clientToken,
322 Details: {
323 Name: name,
324 Regions: news.regions.map((r) => ({
325 Bucket: r.bucket,
326 BucketAccountId: r.bucketAccountId,
327 })),
328 PublicAccessBlock: news.publicAccessBlock
329 ? {
330 BlockPublicAcls: news.publicAccessBlock.blockPublicAcls,
331 IgnorePublicAcls:
332 news.publicAccessBlock.ignorePublicAcls,
333 BlockPublicPolicy:
334 news.publicAccessBlock.blockPublicPolicy,
335 RestrictPublicBuckets:
336 news.publicAccessBlock.restrictPublicBuckets,
337 }
338 : undefined,
339 },
340 }),
341 );
342 }
343
344 // 3. WAIT — converge to READY (also covers resuming a reconcile
345 // that crashed while a previous create was still in flight).
346 const ready = yield* awaitReady(accountId, name);
347
348 yield* session.note(name);
349 return toAttrs(name, accountId, ready);
350 }),
351 delete: Effect.fn(function* ({ output, session }) {
352 const { accountId } = yield* AWSEnvironment.current;
353 const name = output.multiRegionAccessPointName;
354
355 const report = yield* observeMrap(accountId, name);
356 if (report === undefined) return;
357
358 // Deletion is also asynchronous; only submit when the access
359 // point is not already being torn down.
360 if (report.Status !== "DELETING") {
361 const clientToken = yield* Effect.sync(() => crypto.randomUUID());
362 yield* session.note(
363 `Deleting Multi-Region Access Point ${name} (takes several minutes)...`,
364 );
365 yield* inMrapRegion(
366 s3control
367 .deleteMultiRegionAccessPoint({
368 AccountId: accountId,
369 ClientToken: clientToken,
370 Details: { Name: name },
371 })
372 .pipe(
373 Effect.catchTag(
374 "NoSuchMultiRegionAccessPoint",
375 () => Effect.void,
376 ),
377 ),
378 );
379 }
380
381 yield* awaitGone(accountId, name);
382 }),
383 });
384 }),
385 );