1import * as organizations from "@distilled.cloud/aws/organizations";
2import * as Effect from "effect/Effect";
3import { Unowned } from "../../AdoptPolicy.ts";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { hasAlchemyTags } from "../../Tags.ts";
8import type { Providers } from "../Providers.ts";
9import {
10 collectPages,
11 createName,
12 readResourceTags,
13 retryOrganizations,
14 updateResourceTags,
15} from "./common.ts";
16
17export type OrganizationalUnitId = string;
18export type OrganizationalUnitArn = string;
19
20export interface OrganizationalUnitProps {
21 /**
22 * Parent root or OU ID.
23 */
24 parentId: string;
25 /**
26 * OU name. If omitted, Alchemy generates one.
27 */
28 name?: string;
29 /**
30 * Optional tags applied to the OU.
31 */
32 tags?: Record<string, string>;
33}
34
35export interface OrganizationalUnit extends Resource<
36 "AWS.Organizations.OrganizationalUnit",
37 OrganizationalUnitProps,
38 {
39 /**
40 * ID of the OU (e.g. `ou-examplerootid-exampleouid`).
41 */
42 ouId: OrganizationalUnitId;
43 /**
44 * ARN of the OU.
45 */
46 ouArn: OrganizationalUnitArn;
47 /**
48 * Name of the OU.
49 */
50 name: string;
51 /**
52 * ID of the parent root or OU.
53 */
54 parentId: string | undefined;
55 /**
56 * Tags on the OU.
57 */
58 tags: Record<string, string>;
59 },
60 never,
61 Providers
62> {}
63
64/**
65 * An AWS Organizations organizational unit.
66 * ### Creating OUs
67 * **Example:** Nested OU
68 * ```typescript
69 * const workloads = yield* OrganizationalUnit("Workloads", {
70 * parentId: root.rootId,
71 * name: "workloads",
72 * });
73 * ```
74 *
75 * @resource
76 */
77export const OrganizationalUnit = Resource<OrganizationalUnit>(
78 "AWS.Organizations.OrganizationalUnit",
79);
80
81export const OrganizationalUnitProvider = () =>
82 Provider.effect(
83 OrganizationalUnit,
84 Effect.gen(function* () {
85 return {
86 stables: ["ouId", "ouArn"],
87 diff: Effect.fn(function* ({ id, olds, news }) {
88 if (!isResolved(news)) return;
89 const oldName = yield* toName(id, olds ?? {});
90 const newName = yield* toName(id, news ?? {});
91
92 if (olds?.parentId !== news?.parentId) {
93 return { action: "replace" } as const;
94 }
95
96 if (oldName !== newName) {
97 return { action: "update" } as const;
98 }
99 }),
100 // OUs form a tree: enumerate roots, then recursively fan out over
101 // `listOrganizationalUnitsForParent` (bounded concurrency) to discover
102 // every OU. Each discovered OU is hydrated through `readOUById` so the
103 // element shape exactly matches `read`. When the account isn't an
104 // organization management account, the typed
105 // `AWSOrganizationsNotInUseException` / `AccessDeniedException` degrade
106 // to `[]` rather than throwing.
107 list: () =>
108 Effect.gen(function* () {
109 const roots = yield* listAllRoots();
110 const rootIds = roots
111 .map((root) => root.Id)
112 .filter((rootId): rootId is string => rootId !== undefined);
113 const ouIds = yield* collectDescendantOUIds(rootIds);
114 const hydrated = yield* Effect.forEach(
115 ouIds,
116 (ouId) => readOUById(ouId),
117 { concurrency: 10 },
118 );
119 const result: OrganizationalUnit["Attributes"][] = hydrated.filter(
120 (ou): ou is NonNullable<typeof ou> => ou !== undefined,
121 );
122 return result;
123 }).pipe(
124 Effect.catchTags({
125 AWSOrganizationsNotInUseException: () =>
126 Effect.succeed([] as OrganizationalUnit["Attributes"][]),
127 AccessDeniedException: () =>
128 Effect.succeed([] as OrganizationalUnit["Attributes"][]),
129 }),
130 ),
131 read: Effect.fn(function* ({ id, olds, output }) {
132 const state = output?.ouId
133 ? yield* readOUById(output.ouId)
134 : olds?.parentId
135 ? yield* readOUByParentAndName({
136 parentId: olds.parentId,
137 name: yield* toName(id, olds ?? {}),
138 })
139 : undefined;
140 if (!state) return undefined;
141 return (yield* hasAlchemyTags(id, state.tags))
142 ? state
143 : Unowned(state);
144 }),
145 reconcile: Effect.fn(function* ({ id, news, output, session }) {
146 const name = yield* toName(id, news);
147
148 // Observe — locate the OU by ID if known, else by parent+name.
149 // We fetch fresh so the reconciler converges over drift, adoption,
150 // and partial prior runs. `output.ouId` is treated only as a
151 // cache.
152 let state = output?.ouId
153 ? yield* readOUById(output.ouId)
154 : yield* readOUByParentAndName({
155 parentId: news.parentId,
156 name,
157 });
158
159 // Ensure — create the OU if missing. Tolerate
160 // `DuplicateOrganizationalUnitException` as adoption when a peer
161 // reconciler created it concurrently.
162 if (!state) {
163 yield* retryOrganizations(
164 organizations
165 .createOrganizationalUnit({
166 ParentId: news.parentId,
167 Name: name,
168 })
169 .pipe(
170 Effect.catchTag(
171 "DuplicateOrganizationalUnitException",
172 () => Effect.void,
173 ),
174 ),
175 );
176 state = yield* readOUByParentAndName({
177 parentId: news.parentId,
178 name,
179 });
180 if (!state) {
181 return yield* Effect.fail(
182 new Error(
183 `organizational unit '${name}' not found after create`,
184 ),
185 );
186 }
187 }
188
189 // Sync name — observed ↔ desired. `parentId` is replacement-only,
190 // so the diff has handled any cross-parent move.
191 if (state.name !== name) {
192 yield* retryOrganizations(
193 organizations.updateOrganizationalUnit({
194 OrganizationalUnitId: state.ouId,
195 Name: name,
196 }),
197 );
198 }
199
200 // Sync tags — diff observed cloud tags against desired so
201 // adoption and drift converge correctly without trusting `olds`.
202 const tags = yield* updateResourceTags({
203 id,
204 resourceId: state.ouId,
205 olds: state.tags,
206 news: news.tags,
207 });
208
209 const updated = yield* readOUById(state.ouId);
210 if (!updated) {
211 return yield* Effect.fail(
212 new Error(
213 `organizational unit '${state.ouId}' not found after reconcile`,
214 ),
215 );
216 }
217
218 yield* session.note(updated.ouArn);
219 return {
220 ...updated,
221 tags,
222 };
223 }),
224 delete: Effect.fn(function* ({ output }) {
225 yield* retryOrganizations(
226 organizations
227 .deleteOrganizationalUnit({
228 OrganizationalUnitId: output.ouId,
229 })
230 .pipe(
231 Effect.catchTag(
232 "OrganizationalUnitNotFoundException",
233 () => Effect.void,
234 ),
235 ),
236 );
237 }),
238 };
239 }),
240 );
241
242const toName = (id: string, props: { name?: string } = {}) =>
243 createName(id, props.name, 128);
244
245const listOUsForParent = (parentId: string) =>
246 collectPages(
247 (NextToken) =>
248 organizations.listOrganizationalUnitsForParent({
249 ParentId: parentId,
250 NextToken,
251 }),
252 (page) => page.OrganizationalUnits,
253 ).pipe(retryOrganizations);
254
255const listAllRoots = () =>
256 collectPages(
257 (NextToken) => organizations.listRoots({ NextToken }),
258 (page) => page.Roots,
259 ).pipe(retryOrganizations);
260
261// Walk the OU tree breadth-first. Each level fans out across its parents with
262// bounded concurrency so a wide/deep org doesn't issue an unbounded burst.
263const collectDescendantOUIds = (
264 parentIds: readonly string[],
265): Effect.Effect<
266 string[],
267 Effect.Error<ReturnType<typeof listOUsForParent>>,
268 Effect.Services<ReturnType<typeof listOUsForParent>>
269> =>
270 Effect.gen(function* () {
271 if (parentIds.length === 0) return [];
272 const childLists = yield* Effect.forEach(
273 parentIds,
274 (parentId) => listOUsForParent(parentId),
275 { concurrency: 10 },
276 );
277 const childIds = childLists
278 .flat()
279 .map((ou) => ou.Id)
280 .filter((ouId): ouId is string => ouId !== undefined);
281 const descendants = yield* collectDescendantOUIds(childIds);
282 return [...childIds, ...descendants];
283 });
284
285const readParentId = (childId: string) =>
286 collectPages(
287 (NextToken) => organizations.listParents({ ChildId: childId, NextToken }),
288 (page) => page.Parents,
289 ).pipe(
290 retryOrganizations,
291 Effect.map((parents) => parents[0]?.Id),
292 );
293
294const readOUById = Effect.fn(function* (ouId: string) {
295 const described = yield* retryOrganizations(
296 organizations
297 .describeOrganizationalUnit({
298 OrganizationalUnitId: ouId,
299 })
300 .pipe(
301 Effect.map((response) => response.OrganizationalUnit),
302 Effect.catchTag("OrganizationalUnitNotFoundException", () =>
303 Effect.succeed(undefined),
304 ),
305 ),
306 );
307
308 if (!described?.Id || !described.Arn || !described.Name) {
309 return undefined;
310 }
311
312 const [parentId, tags] = yield* Effect.all([
313 readParentId(described.Id).pipe(
314 Effect.catchTag("ChildNotFoundException", () =>
315 Effect.succeed(undefined),
316 ),
317 ),
318 readResourceTags(described.Id).pipe(
319 Effect.catchTag("TargetNotFoundException", () => Effect.succeed({})),
320 ),
321 ]);
322
323 return {
324 ouId: described.Id,
325 ouArn: described.Arn,
326 name: described.Name,
327 parentId,
328 tags,
329 } satisfies OrganizationalUnit["Attributes"];
330});
331
332const readOUByParentAndName = Effect.fn(function* ({
333 parentId,
334 name,
335}: {
336 parentId: string;
337 name: string;
338}) {
339 const match = (yield* listOUsForParent(parentId)).find(
340 (ou) => ou.Name === name,
341 );
342 return match?.Id ? yield* readOUById(match.Id) : undefined;
343});