1import * as s3files from "@distilled.cloud/aws/s3files";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 retryWhileConflict,
12 S3FilesNotConverged,
13 toTagList,
14 toTagRecord,
15 untilSettled,
16} from "./internal.ts";
17
18/**
19 * POSIX identity enforced for all file system operations made through an
20 * access point.
21 */
22export interface AccessPointPosixUser {
23 /**
24 * POSIX user id.
25 */
26 uid: number;
27 /**
28 * POSIX group id.
29 */
30 gid: number;
31 /**
32 * Secondary POSIX group ids.
33 */
34 secondaryGids?: number[];
35}
36
37/**
38 * Ownership and permissions applied when the access point's root directory
39 * is created on first use.
40 */
41export interface AccessPointCreationPermissions {
42 /**
43 * POSIX user id that owns the root directory.
44 */
45 ownerUid: number;
46 /**
47 * POSIX group id that owns the root directory.
48 */
49 ownerGid: number;
50 /**
51 * POSIX permission mode for the root directory (e.g. `"0755"`).
52 */
53 permissions: string;
54}
55
56/**
57 * Root directory the access point exposes as its file system root.
58 */
59export interface AccessPointRootDirectory {
60 /**
61 * Path within the file system to expose as the access point root.
62 * @default "/"
63 */
64 path?: string;
65 /**
66 * Ownership and mode applied if the service creates the root directory.
67 */
68 creationPermissions?: AccessPointCreationPermissions;
69}
70
71export interface AccessPointProps {
72 /**
73 * Id of the {@link FileSystem} the access point attaches to. Changing it
74 * replaces the access point.
75 */
76 fileSystemId: string;
77 /**
78 * POSIX identity enforced for all operations through the access point.
79 * Immutable — changing it replaces the access point.
80 */
81 posixUser?: AccessPointPosixUser;
82 /**
83 * Root directory the access point exposes. Immutable — changing it
84 * replaces the access point.
85 */
86 rootDirectory?: AccessPointRootDirectory;
87 /**
88 * Tags to apply to the access point. Merged with internal Alchemy tags.
89 */
90 tags?: Record<string, string>;
91}
92
93export interface AccessPoint extends Resource<
94 "AWS.S3Files.AccessPoint",
95 AccessPointProps,
96 {
97 /**
98 * Unique ID of the access point.
99 */
100 accessPointId: string;
101 /**
102 * ARN of the access point.
103 */
104 accessPointArn: string;
105 /**
106 * ID of the file system the access point attaches to.
107 */
108 fileSystemId: string;
109 /**
110 * Current lifecycle status of the access point (e.g. `AVAILABLE`).
111 */
112 status: string;
113 },
114 never,
115 Providers
116> {}
117
118/**
119 * An Amazon S3 File System Access Point — application-specific access to a
120 * {@link FileSystem} with POSIX user identity and root directory
121 * enforcement, for managing shared datasets in multi-tenant scenarios.
122 *
123 * ### Creating an Access Point
124 * **Example:** Basic Access Point
125 * ```typescript
126 * import * as AWS from "alchemy/AWS";
127 *
128 * const accessPoint = yield* AWS.S3Files.AccessPoint("AppAccess", {
129 * fileSystemId: fs.fileSystemId,
130 * });
131 * ```
132 *
133 * **Example:** Access Point with POSIX Identity and Root Directory
134 * ```typescript
135 * const accessPoint = yield* AWS.S3Files.AccessPoint("AppAccess", {
136 * fileSystemId: fs.fileSystemId,
137 * posixUser: { uid: 1000, gid: 1000 },
138 * rootDirectory: {
139 * path: "/app",
140 * creationPermissions: { ownerUid: 1000, ownerGid: 1000, permissions: "0755" },
141 * },
142 * });
143 * ```
144 *
145 * @resource
146 */
147export const AccessPoint = Resource<AccessPoint>("AWS.S3Files.AccessPoint");
148
149const replacementKey = (props: Partial<AccessPointProps>) =>
150 JSON.stringify({
151 fileSystemId: props.fileSystemId,
152 posixUser: props.posixUser,
153 rootDirectory: props.rootDirectory,
154 });
155
156export const AccessPointProvider = () =>
157 Provider.effect(
158 AccessPoint,
159 Effect.gen(function* () {
160 const observe = (accessPointId: string) =>
161 s3files
162 .getAccessPoint({ accessPointId })
163 .pipe(
164 Effect.catchTag("ResourceNotFoundException", () =>
165 Effect.succeed(undefined),
166 ),
167 );
168
169 const toAttributes = (ap: s3files.GetAccessPointResponse) => ({
170 accessPointId: ap.accessPointId,
171 accessPointArn: ap.accessPointArn,
172 fileSystemId: ap.fileSystemId,
173 status: ap.status,
174 });
175
176 // State-loss / conflict fallback: scan the parent file system's access
177 // points for the one carrying this logical id's Alchemy tags.
178 const findByAlchemyTags = Effect.fn(function* (
179 id: string,
180 fileSystemId: string,
181 ) {
182 const accessPoints = yield* s3files.listAccessPoints
183 .items({ fileSystemId })
184 .pipe(
185 Stream.runCollect,
186 Effect.map((c) => Array.from(c)),
187 Effect.catchTag("ResourceNotFoundException", () =>
188 Effect.succeed([] as s3files.ListAccessPointsDescription[]),
189 ),
190 );
191 for (const item of accessPoints) {
192 const ap = yield* observe(item.accessPointId);
193 if (
194 ap !== undefined &&
195 (yield* hasAlchemyTags(id, toTagRecord(ap.tags)))
196 ) {
197 return ap;
198 }
199 }
200 return undefined;
201 });
202
203 const syncTags = Effect.fn(function* (
204 accessPointId: string,
205 current: Record<string, string>,
206 desired: Record<string, string>,
207 ) {
208 const { upsert, removed } = diffTags(current, desired);
209 if (upsert.length > 0) {
210 yield* s3files.tagResource({
211 resourceId: accessPointId,
212 tags: upsert.map((t) => ({ key: t.Key, value: t.Value })),
213 });
214 }
215 if (removed.length > 0) {
216 yield* s3files.untagResource({
217 resourceId: accessPointId,
218 tagKeys: removed,
219 });
220 }
221 });
222
223 return AccessPoint.Provider.of({
224 stables: ["accessPointId", "accessPointArn", "fileSystemId"],
225 list: () =>
226 Effect.gen(function* () {
227 const fileSystems = yield* s3files.listFileSystems.items({}).pipe(
228 Stream.runCollect,
229 Effect.map((c) => Array.from(c)),
230 );
231 const out: {
232 accessPointId: string;
233 accessPointArn: string;
234 fileSystemId: string;
235 status: string;
236 }[] = [];
237 for (const fs of fileSystems) {
238 const accessPoints = yield* s3files.listAccessPoints
239 .items({ fileSystemId: fs.fileSystemId })
240 .pipe(
241 Stream.runCollect,
242 Effect.map((c) => Array.from(c)),
243 // the parent can vanish between list and enumerate
244 Effect.catchTag("ResourceNotFoundException", () =>
245 Effect.succeed([] as s3files.ListAccessPointsDescription[]),
246 ),
247 );
248 for (const ap of accessPoints) {
249 out.push({
250 accessPointId: ap.accessPointId,
251 accessPointArn: ap.accessPointArn,
252 fileSystemId: ap.fileSystemId,
253 status: ap.status,
254 });
255 }
256 }
257 return out;
258 }),
259 read: Effect.fn(function* ({ id, olds, output }) {
260 const found =
261 output?.accessPointId !== undefined
262 ? yield* observe(output.accessPointId)
263 : olds?.fileSystemId !== undefined
264 ? yield* findByAlchemyTags(id, olds.fileSystemId)
265 : undefined;
266 if (found === undefined) return undefined;
267 const attrs = toAttributes(found);
268 return (yield* hasAlchemyTags(id, toTagRecord(found.tags)))
269 ? attrs
270 : Unowned(attrs);
271 }),
272 diff: Effect.fn(function* ({ news, olds }) {
273 if (!isResolved(news)) return undefined;
274 // There is no UpdateAccessPoint API — everything except tags is
275 // fixed at create time.
276 if (replacementKey(olds ?? {}) !== replacementKey(news ?? {})) {
277 return { action: "replace" } as const;
278 }
279 // fall through: engine default update (tags sync)
280 }),
281 reconcile: Effect.fn(function* ({ id, news, output, session }) {
282 const clientToken = yield* Effect.sync(() => crypto.randomUUID());
283 const internalTags = yield* createInternalTags(id);
284 const desiredTags = { ...news.tags, ...internalTags };
285
286 // 1. OBSERVE — cloud state is authoritative; output only caches
287 // the server-assigned access point id.
288 let live =
289 output?.accessPointId !== undefined
290 ? yield* observe(output.accessPointId)
291 : yield* findByAlchemyTags(id, news.fileSystemId);
292
293 // 2. ENSURE — create when missing; a concurrent create surfaces
294 // as ConflictException, which we treat as a race and re-find.
295 if (live === undefined) {
296 const created = yield* s3files
297 .createAccessPoint({
298 fileSystemId: news.fileSystemId,
299 posixUser: news.posixUser,
300 rootDirectory: news.rootDirectory,
301 clientToken,
302 tags: toTagList(desiredTags),
303 })
304 .pipe(
305 Effect.catchTag("ConflictException", () =>
306 Effect.succeed(undefined),
307 ),
308 );
309 live =
310 created !== undefined
311 ? yield* observe(created.accessPointId)
312 : yield* findByAlchemyTags(id, news.fileSystemId);
313 }
314 if (live === undefined) {
315 return yield* Effect.fail(
316 new S3FilesNotConverged({
317 resource: `access point on ${news.fileSystemId}`,
318 status: undefined,
319 }),
320 );
321 }
322 const accessPointId = live.accessPointId;
323
324 // Wait (bounded) for the access point to leave `creating`.
325 const settled = yield* untilSettled(
326 observe(accessPointId),
327 (ap) => ap === undefined || ap.status !== "creating",
328 );
329 if (settled === undefined || settled.status !== "available") {
330 return yield* Effect.fail(
331 new S3FilesNotConverged({
332 resource: accessPointId,
333 status: settled?.status,
334 }),
335 );
336 }
337
338 // 3. SYNC TAGS — diff against observed cloud tags so adoption and
339 // drift converge (create-time tags only apply on first create).
340 yield* syncTags(
341 accessPointId,
342 toTagRecord(settled.tags),
343 desiredTags,
344 );
345
346 // 4. RETURN fresh attributes.
347 yield* session.note(accessPointId);
348 return toAttributes(settled);
349 }),
350 delete: Effect.fn(function* ({ output }) {
351 // Idempotent: gone or already deleting counts as success.
352 const live = yield* observe(output.accessPointId);
353 if (
354 live === undefined ||
355 live.status === "deleting" ||
356 live.status === "deleted"
357 ) {
358 return;
359 }
360 yield* retryWhileConflict(
361 s3files.deleteAccessPoint({
362 accessPointId: output.accessPointId,
363 }),
364 ).pipe(
365 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
366 );
367 }),
368 });
369 }),
370 );