1import * as s3control from "@distilled.cloud/aws/s3-control";
2import * as Arr from "effect/Array";
3import * as Effect from "effect/Effect";
4import * as Order from "effect/Order";
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";
13import type { RegionID } from "../Region.ts";
14
15export interface ObjectLambdaAccessPointProps {
16 /**
17 * Name of the Object Lambda Access Point (3-45 characters, lowercase
18 * letters, numbers and hyphens). If omitted, a unique name is generated
19 * from the app, stage and logical ID.
20 *
21 * Changing the name replaces the access point.
22 * @default ${app}-${stage}-${id}
23 */
24 objectLambdaAccessPointName?: string;
25 /**
26 * ARN of the standard (supporting) access point that the Object Lambda
27 * Access Point reads through.
28 */
29 supportingAccessPoint: string;
30 /**
31 * The transformations to apply: which S3 actions to intercept
32 * (`GetObject`, `HeadObject`, `ListObjects`, `ListObjectsV2`) and the
33 * Lambda function that transforms them.
34 */
35 transformationConfigurations: s3control.ObjectLambdaTransformationConfiguration[];
36 /**
37 * Features the Lambda is allowed to pass through untransformed (e.g.
38 * `GetObject-Range`, `GetObject-PartNumber`, `HeadObject-Range`).
39 */
40 allowedFeatures?: s3control.ObjectLambdaAllowedFeature[];
41 /**
42 * Whether CloudWatch request metrics are enabled for the access point.
43 * @default false
44 */
45 cloudWatchMetricsEnabled?: boolean;
46}
47
48export interface ObjectLambdaAccessPoint extends Resource<
49 "AWS.S3Control.ObjectLambdaAccessPoint",
50 ObjectLambdaAccessPointProps,
51 {
52 /**
53 * Name of the Object Lambda Access Point.
54 */
55 objectLambdaAccessPointName: string;
56 /**
57 * ARN of the Object Lambda Access Point
58 * (service `s3-object-lambda`).
59 */
60 objectLambdaAccessPointArn: string;
61 /**
62 * The S3-assigned alias of the Object Lambda Access Point. The alias
63 * can be used anywhere a bucket name is accepted.
64 */
65 alias: string | undefined;
66 /**
67 * AWS region of the access point.
68 */
69 region: RegionID;
70 /**
71 * AWS account ID that owns the access point.
72 */
73 accountId: AccountID;
74 },
75 never,
76 Providers
77> {}
78
79/**
80 * An S3 Object Lambda Access Point — intercepts S3 `GetObject` /
81 * `HeadObject` / `ListObjects` requests through a supporting access point
82 * and transforms responses with a Lambda function (redaction, resizing,
83 * format conversion, ...).
84 * ### Creating Object Lambda Access Points
85 * **Example:** Transform GetObject responses with a Lambda
86 * ```typescript
87 * import * as S3Control from "alchemy/AWS/S3Control";
88 *
89 * const accessPoint = yield* S3Control.AccessPoint("data-ap", {
90 * bucket: bucket.bucketName,
91 * });
92 *
93 * const olap = yield* S3Control.ObjectLambdaAccessPoint("transform-ap", {
94 * supportingAccessPoint: accessPoint.accessPointArn,
95 * transformationConfigurations: [
96 * {
97 * Actions: ["GetObject"],
98 * ContentTransformation: {
99 * AwsLambda: { FunctionArn: transformer.functionArn },
100 * },
101 * },
102 * ],
103 * });
104 * ```
105 *
106 * **Example:** Pass Range/PartNumber through and enable metrics
107 * ```typescript
108 * const olap = yield* S3Control.ObjectLambdaAccessPoint("transform-ap", {
109 * supportingAccessPoint: accessPoint.accessPointArn,
110 * allowedFeatures: ["GetObject-Range", "GetObject-PartNumber"],
111 * cloudWatchMetricsEnabled: true,
112 * transformationConfigurations: [
113 * {
114 * Actions: ["GetObject"],
115 * ContentTransformation: {
116 * AwsLambda: { FunctionArn: transformer.functionArn },
117 * },
118 * },
119 * ],
120 * });
121 * ```
122 *
123 * @resource
124 */
125export const ObjectLambdaAccessPoint = Resource<ObjectLambdaAccessPoint>(
126 "AWS.S3Control.ObjectLambdaAccessPoint",
127);
128
129/**
130 * Retry while a freshly-created Object Lambda Access Point has not
131 * propagated to reads yet.
132 *
133 * Explicitly typed at module scope — inlining `Effect.retry` in a lifecycle
134 * op leaks `Retry.Return`'s conditional type into declaration emit, widening
135 * the provider layer to `unknown` and poisoning `AWS.providers()`.
136 */
137const retryWhileObjectLambdaPropagates = <A, E extends { _tag: string }, R>(
138 self: Effect.Effect<A, E, R>,
139): Effect.Effect<A, E, R> =>
140 Effect.retry(self, {
141 while: (e) => e._tag === "NoSuchAccessPoint",
142 schedule: Schedule.max([Schedule.fixed("1 second"), Schedule.recurs(10)]),
143 });
144
145export const ObjectLambdaAccessPointProvider = () =>
146 Provider.effect(
147 ObjectLambdaAccessPoint,
148 Effect.gen(function* () {
149 const createName = Effect.fn(function* (
150 id: string,
151 props: Pick<
152 ObjectLambdaAccessPointProps,
153 "objectLambdaAccessPointName"
154 >,
155 ) {
156 // Object Lambda Access Point names: 3-45 chars, lowercase.
157 return (
158 props.objectLambdaAccessPointName ??
159 (yield* createPhysicalName({ id, maxLength: 45, lowercase: true }))
160 );
161 });
162
163 const objectLambdaArn = (
164 region: string,
165 accountId: string,
166 name: string,
167 ) =>
168 `arn:aws:s3-object-lambda:${region}:${accountId}:accesspoint/${name}`;
169
170 const observeAccessPoint = (accountId: string, name: string) =>
171 s3control
172 .getAccessPointForObjectLambda({ AccountId: accountId, Name: name })
173 .pipe(
174 Effect.catchTag("NoSuchAccessPoint", () =>
175 Effect.succeed(undefined),
176 ),
177 );
178
179 const desiredConfiguration = (
180 props: ObjectLambdaAccessPointProps,
181 ): s3control.ObjectLambdaConfiguration => ({
182 SupportingAccessPoint: props.supportingAccessPoint,
183 CloudWatchMetricsEnabled: props.cloudWatchMetricsEnabled ?? false,
184 AllowedFeatures: props.allowedFeatures,
185 TransformationConfigurations: props.transformationConfigurations,
186 });
187
188 // Canonical form ignoring member ordering so re-ordering never reads
189 // as drift.
190 const canon = (cfg: s3control.ObjectLambdaConfiguration) =>
191 JSON.stringify({
192 supportingAccessPoint: cfg.SupportingAccessPoint,
193 metrics: cfg.CloudWatchMetricsEnabled ?? false,
194 features: Arr.sort(cfg.AllowedFeatures ?? [], Order.String),
195 transformations: Arr.map(
196 cfg.TransformationConfigurations,
197 (transformation) => ({
198 actions: Arr.sort(transformation.Actions, Order.String),
199 lambda: transformation.ContentTransformation.AwsLambda,
200 }),
201 ),
202 });
203
204 return ObjectLambdaAccessPoint.Provider.of({
205 stables: [
206 "objectLambdaAccessPointName",
207 "objectLambdaAccessPointArn",
208 "alias",
209 "region",
210 "accountId",
211 ],
212 list: () =>
213 Effect.gen(function* () {
214 const { accountId, region } = yield* AWSEnvironment.current;
215 const pages = yield* s3control.listAccessPointsForObjectLambda
216 .pages({ AccountId: accountId })
217 .pipe(Stream.runCollect);
218 return Array.from(pages).flatMap((page) =>
219 (page.ObjectLambdaAccessPointList ?? []).map((olap) => ({
220 objectLambdaAccessPointName: olap.Name,
221 objectLambdaAccessPointArn:
222 olap.ObjectLambdaAccessPointArn ??
223 objectLambdaArn(region, accountId, olap.Name),
224 alias: olap.Alias?.Value,
225 region,
226 accountId,
227 })),
228 );
229 }),
230 // Object Lambda Access Points do not support tags, so a read hit is
231 // treated as ours (ownership is scoped by the deterministic name).
232 read: Effect.fn(function* ({ id, olds, output }) {
233 const { accountId, region } = yield* AWSEnvironment.current;
234 const name =
235 output?.objectLambdaAccessPointName ??
236 (yield* createName(id, olds ?? {}));
237 const live = yield* observeAccessPoint(accountId, name);
238 if (live === undefined) return undefined;
239 return {
240 objectLambdaAccessPointName: name,
241 objectLambdaAccessPointArn: objectLambdaArn(
242 region,
243 accountId,
244 name,
245 ),
246 alias: live.Alias?.Value,
247 region,
248 accountId,
249 };
250 }),
251 diff: Effect.fn(function* ({ id, news, olds }) {
252 if (!isResolved(news)) return undefined;
253 const oldName = yield* createName(id, olds ?? {});
254 const newName = yield* createName(id, news);
255 if (oldName !== newName) {
256 return { action: "replace" } as const;
257 }
258 // fall through: the configuration converges via
259 // PutAccessPointConfigurationForObjectLambda
260 }),
261 reconcile: Effect.fn(function* ({ id, news, output, session }) {
262 const { accountId, region } = yield* AWSEnvironment.current;
263 const name =
264 output?.objectLambdaAccessPointName ??
265 (yield* createName(id, news));
266 const desired = desiredConfiguration(news);
267
268 // 1. OBSERVE — cloud state is authoritative.
269 let live = yield* observeAccessPoint(accountId, name);
270
271 // 2. ENSURE — create when missing; tolerate the AlreadyOwnedByYou
272 // race and wait out read propagation.
273 if (live === undefined) {
274 yield* s3control
275 .createAccessPointForObjectLambda({
276 AccountId: accountId,
277 Name: name,
278 Configuration: desired,
279 })
280 .pipe(
281 Effect.catchTag(
282 "AccessPointAlreadyOwnedByYou",
283 () => Effect.void,
284 ),
285 );
286 live = yield* retryWhileObjectLambdaPropagates(
287 s3control.getAccessPointForObjectLambda({
288 AccountId: accountId,
289 Name: name,
290 }),
291 );
292 }
293
294 // 3. SYNC — diff the OBSERVED configuration against the desired
295 // one; apply only on drift.
296 const observedConfig = yield* retryWhileObjectLambdaPropagates(
297 s3control.getAccessPointConfigurationForObjectLambda({
298 AccountId: accountId,
299 Name: name,
300 }),
301 );
302 if (
303 observedConfig.Configuration === undefined ||
304 canon(observedConfig.Configuration) !== canon(desired)
305 ) {
306 yield* s3control.putAccessPointConfigurationForObjectLambda({
307 AccountId: accountId,
308 Name: name,
309 Configuration: desired,
310 });
311 }
312
313 yield* session.note(name);
314 return {
315 objectLambdaAccessPointName: name,
316 objectLambdaAccessPointArn: objectLambdaArn(
317 region,
318 accountId,
319 name,
320 ),
321 alias: live.Alias?.Value,
322 region,
323 accountId,
324 };
325 }),
326 delete: Effect.fn(function* ({ output }) {
327 const { accountId } = yield* AWSEnvironment.current;
328 yield* s3control
329 .deleteAccessPointForObjectLambda({
330 AccountId: accountId,
331 Name: output.objectLambdaAccessPointName,
332 })
333 .pipe(
334 // Idempotent delete — already gone is success.
335 Effect.catchTag("NoSuchAccessPoint", () => Effect.void),
336 );
337 }),
338 });
339 }),
340 );