1import * as ram from "@distilled.cloud/aws/ram";
2import * as Array from "effect/Array";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
11import type { Providers } from "../Providers.ts";
12
13export interface ResourceShareProps {
14 /**
15 * Name of the resource share. Must be unique within your account. If omitted,
16 * Alchemy generates a deterministic name from the stack, stage, and logical ID.
17 * Max 128 characters.
18 */
19 shareName?: string;
20
21 /**
22 * ARNs of the resources to share (for example a Subnet, License, or Resolver
23 * rule ARN). Resources can be added or removed on update.
24 */
25 resourceArns?: string[];
26
27 /**
28 * Principals to share with. Each principal is one of: a 12-digit account ID,
29 * an organization ARN, an organizational unit (OU) ARN, an IAM role ARN, or an
30 * IAM user ARN. Principals can be added or removed on update.
31 */
32 principals?: string[];
33
34 /**
35 * Source accounts or ARNs whose resources are shared through this share.
36 * Used by service-managed shares. Sources can be added or removed on update.
37 */
38 sources?: string[];
39
40 /**
41 * Whether principals outside your AWS organization are allowed. When sharing
42 * with accounts or OUs inside your organization (with RAM organization sharing
43 * enabled) set this to `false` so shares are auto-accepted.
44 * @default true
45 */
46 allowExternalPrincipals?: boolean;
47
48 /**
49 * ARNs of the RAM managed permissions to associate with the share at creation
50 * time. Changing this list replaces the resource share.
51 */
52 permissionArns?: string[];
53
54 /**
55 * Tags applied to the resource share.
56 */
57 tags?: Record<string, string>;
58}
59
60export interface ResourceShare extends Resource<
61 "AWS.RAM.ResourceShare",
62 ResourceShareProps,
63 {
64 /** ARN of the resource share. */
65 resourceShareArn: string;
66 /** Name of the resource share. */
67 name: string;
68 /** Account ID that owns the resource share. */
69 owningAccountId: string | undefined;
70 /** Whether principals outside the organization are allowed. */
71 allowExternalPrincipals: boolean | undefined;
72 /** Current lifecycle status (`ACTIVE`, `PENDING`, `FAILED`, ...). */
73 status: string | undefined;
74 /** Tags applied to the resource share. */
75 tags: Record<string, string>;
76 },
77 never,
78 Providers
79> {}
80
81/**
82 * An AWS Resource Access Manager (RAM) resource share.
83 *
84 * A resource share grants principals (accounts, organizational units, or IAM
85 * identities) access to a set of shared resources identified by ARN.
86 *
87 * ### Creating a Resource Share
88 * **Example:** Share subnets with an organizational unit
89 * ```typescript
90 * const share = yield* ResourceShare("NetworkShare", {
91 * resourceArns: [subnet.subnetArn],
92 * principals: [ou.ouArn],
93 * allowExternalPrincipals: false,
94 * });
95 * ```
96 *
97 * **Example:** Share with an external account
98 * ```typescript
99 * const share = yield* ResourceShare("ExternalShare", {
100 * resourceArns: [resolverRule.arn],
101 * principals: ["123456789012"],
102 * allowExternalPrincipals: true,
103 * tags: { team: "platform" },
104 * });
105 * ```
106 *
107 * @resource
108 */
109export const ResourceShare = Resource<ResourceShare>("AWS.RAM.ResourceShare");
110
111const toName = (id: string, props: { shareName?: string } = {}) =>
112 props.shareName
113 ? Effect.succeed(props.shareName)
114 : createPhysicalName({ id, maxLength: 128 });
115
116/** RAM's inline `{ key, value }` tag list → a plain `Record`. */
117const tagsToRecord = (
118 tags: readonly ram.Tag[] | undefined,
119): Record<string, string> => {
120 const record: Record<string, string> = {};
121 for (const tag of tags ?? []) {
122 if (tag.key !== undefined && tag.value !== undefined) {
123 record[tag.key] = tag.value;
124 }
125 }
126 return record;
127};
128
129/** A resource share is "live" only while it is not deleting/deleted. */
130const isLive = (status: string | undefined): boolean =>
131 status !== "DELETING" && status !== "DELETED";
132
133const toAttrs = (share: ram.ResourceShare): ResourceShare["Attributes"] => ({
134 resourceShareArn: share.resourceShareArn!,
135 name: share.name!,
136 owningAccountId: share.owningAccountId,
137 allowExternalPrincipals: share.allowExternalPrincipals,
138 status: share.status,
139 tags: tagsToRecord(share.tags),
140});
141
142/** Read a single share by ARN; returns undefined if missing or deleted. */
143const readByArn = Effect.fn(function* (arn: string) {
144 const shares = yield* ram
145 .getResourceShares({ resourceOwner: "SELF", resourceShareArns: [arn] })
146 .pipe(
147 Effect.map((r) => r.resourceShares ?? []),
148 Effect.catchTag("UnknownResourceException", () => Effect.succeed([])),
149 );
150 const share = shares.find(
151 (s) => s.resourceShareArn === arn && isLive(s.status),
152 );
153 return share ? toAttrs(share) : undefined;
154});
155
156/** Find a live share owned by us with the given name. */
157const readByName = Effect.fn(function* (name: string) {
158 const shares = yield* ram
159 .getResourceShares({ resourceOwner: "SELF", name })
160 .pipe(
161 Effect.map((r) => r.resourceShares ?? []),
162 Effect.catchTag("UnknownResourceException", () => Effect.succeed([])),
163 );
164 const share = shares.find((s) => s.name === name && isLive(s.status));
165 return share ? toAttrs(share) : undefined;
166});
167
168/**
169 * Collect the associated entities of a given type that are currently attached
170 * (associating/associated), so we can diff against the desired set.
171 */
172const readAssociations = Effect.fn(function* (
173 arn: string,
174 associationType: "PRINCIPAL" | "RESOURCE",
175) {
176 const associations = yield* ram.getResourceShareAssociations
177 .pages({ resourceShareArns: [arn], associationType })
178 .pipe(
179 Stream.runCollect,
180 Effect.map((chunk) =>
181 Array.fromIterable(chunk).flatMap(
182 (page) => page.resourceShareAssociations ?? [],
183 ),
184 ),
185 Effect.catchTag("UnknownResourceException", () => Effect.succeed([])),
186 );
187 return associations
188 .filter((a) => a.status === "ASSOCIATED" || a.status === "ASSOCIATING")
189 .map((a) => a.associatedEntity)
190 .filter((entity): entity is string => entity !== undefined);
191});
192
193export const ResourceShareProvider = () =>
194 Provider.effect(
195 ResourceShare,
196 Effect.gen(function* () {
197 return {
198 stables: ["resourceShareArn", "owningAccountId"],
199 diff: Effect.fn(function* ({ news, olds }) {
200 if (!isResolved(news)) return;
201 // permissionArns are fixed at creation; changing the set replaces.
202 const oldPerms = [...(olds?.permissionArns ?? [])].sort();
203 const newPerms = [...(news.permissionArns ?? [])].sort();
204 if (
205 oldPerms.length !== newPerms.length ||
206 oldPerms.some((p, i) => p !== newPerms[i])
207 ) {
208 return { action: "replace" } as const;
209 }
210 }),
211 list: () =>
212 ram.getResourceShares.pages({ resourceOwner: "SELF" }).pipe(
213 Stream.runCollect,
214 Effect.map((chunk) =>
215 Array.fromIterable(chunk)
216 .flatMap((page) => page.resourceShares ?? [])
217 .filter(
218 (s) =>
219 isLive(s.status) &&
220 s.resourceShareArn !== undefined &&
221 s.name !== undefined,
222 )
223 .map(toAttrs),
224 ),
225 ),
226 read: Effect.fn(function* ({ id, olds, output }) {
227 const state = output?.resourceShareArn
228 ? yield* readByArn(output.resourceShareArn)
229 : yield* readByName(yield* toName(id, olds ?? {}));
230 if (!state) return undefined;
231 return (yield* hasAlchemyTags(id, state.tags))
232 ? state
233 : Unowned(state);
234 }),
235 reconcile: Effect.fn(function* ({ id, news, output, session }) {
236 const name = yield* toName(id, news);
237 const internalTags = yield* createInternalTags(id);
238 const desiredTags = { ...news.tags, ...internalTags };
239 const tagList = Object.entries(desiredTags).map(([key, value]) => ({
240 key,
241 value,
242 }));
243
244 const desiredPrincipals = news.principals ?? [];
245 const desiredResources = news.resourceArns ?? [];
246 const desiredSources = news.sources ?? [];
247
248 // 1. OBSERVE — output ARN is only a cache; re-read live state.
249 let state = output?.resourceShareArn
250 ? yield* readByArn(output.resourceShareArn)
251 : yield* readByName(name);
252
253 // 2. ENSURE — create if missing. On create RAM attaches the initial
254 // resources, principals, sources, permissions, and tags directly.
255 if (!state) {
256 const created = yield* ram.createResourceShare({
257 name,
258 resourceArns:
259 desiredResources.length > 0 ? desiredResources : undefined,
260 principals:
261 desiredPrincipals.length > 0 ? desiredPrincipals : undefined,
262 sources: desiredSources.length > 0 ? desiredSources : undefined,
263 allowExternalPrincipals: news.allowExternalPrincipals,
264 permissionArns:
265 news.permissionArns && news.permissionArns.length > 0
266 ? news.permissionArns
267 : undefined,
268 tags: tagList,
269 });
270 const share = created.resourceShare;
271 if (!share?.resourceShareArn) {
272 return yield* Effect.fail(
273 new Error(`resource share '${name}' missing ARN after create`),
274 );
275 }
276 yield* session.note(share.resourceShareArn);
277 return toAttrs(share);
278 }
279
280 const arn = state.resourceShareArn;
281
282 // 3a. SYNC settings — name / allowExternalPrincipals via update.
283 if (
284 state.name !== name ||
285 (news.allowExternalPrincipals !== undefined &&
286 state.allowExternalPrincipals !== news.allowExternalPrincipals)
287 ) {
288 yield* ram.updateResourceShare({
289 resourceShareArn: arn,
290 name,
291 allowExternalPrincipals: news.allowExternalPrincipals,
292 });
293 }
294
295 // 3b. SYNC associations — diff observed vs desired for principals,
296 // resources, and sources; associate/disassociate the delta.
297 const currentPrincipals = yield* readAssociations(arn, "PRINCIPAL");
298 const currentResources = yield* readAssociations(arn, "RESOURCE");
299
300 const principalsToAdd = desiredPrincipals.filter(
301 (p) => !currentPrincipals.includes(p),
302 );
303 const principalsToRemove = currentPrincipals.filter(
304 (p) => !desiredPrincipals.includes(p),
305 );
306 const resourcesToAdd = desiredResources.filter(
307 (r) => !currentResources.includes(r),
308 );
309 const resourcesToRemove = currentResources.filter(
310 (r) => !desiredResources.includes(r),
311 );
312
313 if (principalsToAdd.length > 0 || resourcesToAdd.length > 0) {
314 yield* ram.associateResourceShare({
315 resourceShareArn: arn,
316 principals:
317 principalsToAdd.length > 0 ? principalsToAdd : undefined,
318 resourceArns:
319 resourcesToAdd.length > 0 ? resourcesToAdd : undefined,
320 });
321 }
322 if (principalsToRemove.length > 0 || resourcesToRemove.length > 0) {
323 yield* ram.disassociateResourceShare({
324 resourceShareArn: arn,
325 principals:
326 principalsToRemove.length > 0 ? principalsToRemove : undefined,
327 resourceArns:
328 resourcesToRemove.length > 0 ? resourcesToRemove : undefined,
329 });
330 }
331
332 // 3c. SYNC tags — diff against observed cloud tags.
333 const { upsert, removed } = diffTags(state.tags, desiredTags);
334 if (upsert.length > 0) {
335 yield* ram.tagResource({
336 resourceShareArn: arn,
337 tags: upsert.map((t) => ({ key: t.Key, value: t.Value })),
338 });
339 }
340 if (removed.length > 0) {
341 yield* ram.untagResource({
342 resourceShareArn: arn,
343 tagKeys: removed,
344 });
345 }
346
347 // 4. RETURN fresh state.
348 const updated = yield* readByArn(arn);
349 yield* session.note(arn);
350 return updated ?? { ...state, name, tags: desiredTags };
351 }),
352 delete: Effect.fn(function* ({ output }) {
353 yield* ram
354 .deleteResourceShare({
355 resourceShareArn: output.resourceShareArn,
356 })
357 .pipe(
358 Effect.catchTag("UnknownResourceException", () => Effect.void),
359 // Already transitioning to DELETED from a prior attempt.
360 Effect.catchTag(
361 "InvalidStateTransitionException",
362 () => Effect.void,
363 ),
364 );
365 }),
366 };
367 }),
368 );