1import * as lf from "@distilled.cloud/aws/lakeformation";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource as AlchemyResource } from "../../Resource.ts";
7import type { Providers } from "../Providers.ts";
8
9export interface ResourceProps {
10 /**
11 * ARN of the S3 location to register with Lake Formation (e.g.
12 * `arn:aws:s3:::my-bucket` or `arn:aws:s3:::my-bucket/prefix`). Changing it
13 * replaces the registration.
14 */
15 resourceArn: string;
16 /**
17 * IAM role Lake Formation uses to vend credentials for the location. When
18 * omitted, the `AWSServiceRoleForLakeFormationDataAccess` service-linked
19 * role is used (and created on first registration).
20 */
21 roleArn?: string;
22 /**
23 * Register using the Lake Formation service-linked role. Only meaningful
24 * when `roleArn` is omitted.
25 * @default true when `roleArn` is omitted
26 */
27 useServiceLinkedRole?: boolean;
28 /**
29 * Whether to register the location with federation (Redshift data sharing).
30 * @default false
31 */
32 withFederation?: boolean;
33 /**
34 * Allow both Lake Formation permissions and IAM/S3 policies to govern
35 * access to the location (hybrid access mode).
36 * @default false
37 */
38 hybridAccessEnabled?: boolean;
39}
40
41export interface Resource extends AlchemyResource<
42 "AWS.LakeFormation.Resource",
43 ResourceProps,
44 {
45 resourceArn: string;
46 roleArn: string | undefined;
47 withFederation: boolean | undefined;
48 hybridAccessEnabled: boolean | undefined;
49 },
50 {},
51 Providers
52> {}
53
54/**
55 * Registers an S3 location as managed by AWS Lake Formation, so Lake
56 * Formation can vend temporary credentials for data stored there
57 * (`DATA_LOCATION_ACCESS` grants, governed tables, etc.).
58 *
59 * ### Registering Locations
60 * **Example:** Register a Bucket with the Service-Linked Role
61 * ```typescript
62 * import * as AWS from "alchemy/AWS";
63 *
64 * const bucket = yield* AWS.S3.Bucket("DataLake", {});
65 * const location = yield* AWS.LakeFormation.Resource("DataLakeLocation", {
66 * resourceArn: bucket.bucketArn,
67 * });
68 * ```
69 *
70 * **Example:** Register with a Custom Data-Access Role
71 * ```typescript
72 * const location = yield* AWS.LakeFormation.Resource("DataLakeLocation", {
73 * resourceArn: bucket.bucketArn,
74 * roleArn: dataAccessRole.roleArn,
75 * hybridAccessEnabled: true,
76 * });
77 * ```
78 *
79 * @resource
80 */
81export const Resource = AlchemyResource<Resource>("AWS.LakeFormation.Resource");
82
83export const ResourceProvider = () =>
84 Provider.effect(
85 Resource,
86 Effect.gen(function* () {
87 const observe = Effect.fn(function* (resourceArn: string) {
88 return yield* lf.describeResource({ ResourceArn: resourceArn }).pipe(
89 Effect.map((r) => r.ResourceInfo),
90 Effect.catchTag("EntityNotFoundException", () =>
91 Effect.succeed(undefined),
92 ),
93 );
94 });
95
96 return Resource.Provider.of({
97 stables: ["resourceArn"],
98
99 list: () =>
100 Effect.gen(function* () {
101 const pages = yield* lf.listResources
102 .pages({})
103 .pipe(Stream.runCollect);
104 return Array.from(pages)
105 .flatMap((page) => page.ResourceInfoList ?? [])
106 .filter((info) => info.ResourceArn !== undefined)
107 .map((info) => ({
108 resourceArn: info.ResourceArn!,
109 roleArn: info.RoleArn,
110 withFederation: info.WithFederation,
111 hybridAccessEnabled: info.HybridAccessEnabled,
112 }));
113 }),
114
115 read: Effect.fn(function* ({ olds, output }) {
116 const resourceArn = output?.resourceArn ?? olds?.resourceArn;
117 if (resourceArn === undefined) return undefined;
118 const info = yield* observe(resourceArn);
119 if (info === undefined) return undefined;
120 // Registrations are not taggable, so ownership cannot be verified —
121 // return the attributes directly.
122 return {
123 resourceArn,
124 roleArn: info.RoleArn,
125 withFederation: info.WithFederation,
126 hybridAccessEnabled: info.HybridAccessEnabled,
127 };
128 }),
129
130 diff: Effect.fn(function* ({ news, olds }) {
131 if (!isResolved(news)) return undefined;
132 if (news.resourceArn !== olds.resourceArn) {
133 return { action: "replace" } as const;
134 }
135 // roleArn / withFederation / hybridAccessEnabled → update
136 }),
137
138 reconcile: Effect.fn(function* ({ news, session }) {
139 const resourceArn = news.resourceArn;
140 const register = lf.registerResource({
141 ResourceArn: resourceArn,
142 UseServiceLinkedRole:
143 news.roleArn !== undefined
144 ? undefined
145 : (news.useServiceLinkedRole ?? true),
146 RoleArn: news.roleArn,
147 WithFederation: news.withFederation,
148 HybridAccessEnabled: news.hybridAccessEnabled,
149 });
150
151 // 1. OBSERVE
152 let info = yield* observe(resourceArn);
153
154 // 2. ENSURE
155 if (info === undefined) {
156 yield* register.pipe(
157 Effect.catchTag("AlreadyExistsException", () => Effect.void),
158 );
159 info = yield* observe(resourceArn);
160 } else {
161 // 3. SYNC — role and registration flags, observed vs desired.
162 const desiredRole = news.roleArn;
163 const roleDrift =
164 desiredRole !== undefined && desiredRole !== info.RoleArn;
165 const hybridDrift =
166 news.hybridAccessEnabled !== undefined &&
167 news.hybridAccessEnabled !== (info.HybridAccessEnabled ?? false);
168 const federationDrift =
169 news.withFederation !== undefined &&
170 news.withFederation !== (info.WithFederation ?? false);
171 if (roleDrift || hybridDrift || federationDrift) {
172 const slrManaged =
173 info.RoleArn?.includes(
174 "/aws-service-role/lakeformation.amazonaws.com/",
175 ) ?? false;
176 if (slrManaged) {
177 // UpdateResource rejects registrations held by the
178 // service-linked role ("Resource managed by Service Linked
179 // Role") — recreate the registration in place instead.
180 yield* lf.deregisterResource({ ResourceArn: resourceArn }).pipe(
181 Effect.catchTag("EntityNotFoundException", () => Effect.void),
182 // spurious error on the last SLR location — the
183 // deregistration still succeeds (see delete below)
184 Effect.catchTag(
185 "LastServiceLinkedRoleRegistration",
186 () => Effect.void,
187 ),
188 );
189 yield* register;
190 } else {
191 const updateRole = desiredRole ?? info.RoleArn;
192 if (updateRole !== undefined) {
193 yield* lf.updateResource({
194 ResourceArn: resourceArn,
195 RoleArn: updateRole,
196 WithFederation: news.withFederation,
197 HybridAccessEnabled: news.hybridAccessEnabled,
198 });
199 }
200 }
201 info = yield* observe(resourceArn);
202 }
203 }
204
205 yield* session.note(resourceArn);
206 return {
207 resourceArn,
208 roleArn: info?.RoleArn,
209 withFederation: info?.WithFederation,
210 hybridAccessEnabled: info?.HybridAccessEnabled,
211 };
212 }),
213
214 delete: Effect.fn(function* ({ output }) {
215 yield* lf
216 .deregisterResource({ ResourceArn: output.resourceArn })
217 .pipe(
218 Effect.catchTag("EntityNotFoundException", () => Effect.void),
219 // Deregistering the LAST location registered with the
220 // service-linked role returns "Must manually delete
221 // service-linked role to deregister last S3 location" even
222 // though the registration IS removed (verified live). Verify
223 // and only re-fail if the registration is still present.
224 Effect.catchTag("LastServiceLinkedRoleRegistration", (error) =>
225 lf.describeResource({ ResourceArn: output.resourceArn }).pipe(
226 Effect.flatMap(() => Effect.fail(error)),
227 Effect.catchTag("EntityNotFoundException", () => Effect.void),
228 ),
229 ),
230 );
231 }),
232 });
233 }),
234 );