1import * as organizations from "@distilled.cloud/aws/organizations";
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 } from "../../Resource.ts";
7import type { Providers } from "../Providers.ts";
8import {
9 collectPages,
10 readResourceTags,
11 retryOrganizations,
12 updateResourceTags,
13} from "./common.ts";
14
15export type RootId = string;
16export type RootArn = string;
17
18export interface RootProps {
19 /**
20 * Optional root ID to import explicitly.
21 * If omitted, Alchemy adopts the single organization root.
22 */
23 rootId?: string;
24 /**
25 * Optional root name to match when multiple roots are ever supported.
26 */
27 name?: string;
28 /**
29 * Optional tags to apply to the imported root.
30 */
31 tags?: Record<string, string>;
32}
33
34export interface Root extends Resource<
35 "AWS.Organizations.Root",
36 RootProps,
37 {
38 /**
39 * ID of the root (e.g. `r-examplerootid`).
40 */
41 rootId: RootId;
42 /**
43 * ARN of the root.
44 */
45 rootArn: RootArn;
46 /**
47 * Friendly name of the root (usually `Root`).
48 */
49 rootName: string;
50 /**
51 * Policy types and their enablement status on this root.
52 */
53 policyTypes: organizations.PolicyTypeSummary[];
54 /**
55 * Tags on the root.
56 */
57 tags: Record<string, string>;
58 },
59 never,
60 Providers
61> {}
62
63/**
64 * The organization root.
65 *
66 * `Root` is an import-style resource. It discovers the existing root returned by
67 * AWS Organizations and can reconcile root tags. Use `root.rootId` as the
68 * `parentId` for top-level {@link OrganizationalUnit}s and {@link Account}s,
69 * and as the `targetId`/`rootId` for {@link PolicyAttachment} and
70 * {@link RootPolicyType}.
71 * ### Importing the Root
72 * **Example:** Adopt the Organization Root
73 * ```typescript
74 * const organization = yield* Organization("Org", { featureSet: "ALL" });
75 * const root = yield* Root("Root", {});
76 * ```
77 *
78 * **Example:** Parent OUs and Accounts Under the Root
79 * ```typescript
80 * const workloads = yield* OrganizationalUnit("Workloads", {
81 * parentId: root.rootId,
82 * name: "workloads",
83 * });
84 *
85 * const sandbox = yield* Account("Sandbox", {
86 * name: "sandbox",
87 * email: "aws-sandbox@example.com",
88 * parentId: root.rootId,
89 * });
90 * ```
91 *
92 * @resource
93 */
94export const Root = Resource<Root>("AWS.Organizations.Root");
95
96export const RootProvider = () =>
97 Provider.effect(
98 Root,
99 Effect.gen(function* () {
100 return {
101 stables: ["rootId", "rootArn"],
102 // Enumerate every organization root via `listRoots` (paginated) and
103 // hydrate each into the exact `read` Attributes shape, fetching tags
104 // with bounded concurrency. Degrades to `[]` when the caller isn't an
105 // organization management account (the account isn't in an org, or
106 // lacks Organizations permissions) via the typed catches below.
107 list: () =>
108 Effect.gen(function* () {
109 const roots = yield* retryOrganizations(
110 organizations.listRoots.pages({}).pipe(
111 Stream.runCollect,
112 Effect.map((chunk) =>
113 Array.from(chunk).flatMap((page) => page.Roots ?? []),
114 ),
115 ),
116 ).pipe(
117 Effect.catchTags({
118 AWSOrganizationsNotInUseException: () => Effect.succeed([]),
119 AccessDeniedException: () => Effect.succeed([]),
120 }),
121 );
122
123 const valid = roots.filter(
124 (
125 root,
126 ): root is typeof root & {
127 Id: string;
128 Arn: string;
129 Name: string;
130 } => root.Id != null && root.Arn != null && root.Name != null,
131 );
132
133 const attrs: (Root["Attributes"] | undefined)[] =
134 yield* Effect.forEach(
135 valid,
136 Effect.fn(function* (root) {
137 if (!root.Id || !root.Arn || !root.Name) return undefined;
138 const tags = yield* readResourceTags(root.Id).pipe(
139 Effect.catchTag("TargetNotFoundException", () =>
140 Effect.succeed({}),
141 ),
142 );
143 return {
144 rootId: root.Id,
145 rootArn: root.Arn,
146 rootName: root.Name,
147 policyTypes: root.PolicyTypes ?? [],
148 tags,
149 };
150 }),
151 { concurrency: 10 },
152 );
153 return attrs.filter(
154 (attr): attr is Root["Attributes"] => attr !== undefined,
155 );
156 }),
157 diff: Effect.fn(function* ({ olds, news }) {
158 if (!isResolved(news)) return;
159 if (olds?.rootId !== news?.rootId) {
160 return { action: "replace" } as const;
161 }
162 if (olds?.name !== news?.name) {
163 return { action: "replace" } as const;
164 }
165 }),
166 read: Effect.fn(function* ({ olds, output }) {
167 return yield* readRoot({
168 rootId: output?.rootId ?? olds?.rootId,
169 name: olds?.name,
170 });
171 }),
172 reconcile: Effect.fn(function* ({ id, news, output, session }) {
173 // Observe — discover the live root. `Root` is import-only: AWS
174 // creates the root for us when the organization is created, and we
175 // never create or delete it. We always look it up fresh.
176 const root = yield* readRoot({
177 rootId: output?.rootId ?? news.rootId,
178 name: news.name,
179 });
180 if (!root) {
181 return yield* Effect.fail(
182 new Error(
183 output?.rootId
184 ? `organization root '${output.rootId}' not found`
185 : "organization root not found",
186 ),
187 );
188 }
189
190 // Sync tags — diff observed cloud tags against desired. Using
191 // `root.tags` as the baseline (instead of stale `olds.tags`) keeps
192 // the reconciler convergent even on adoption or after drift.
193 const tags = yield* updateResourceTags({
194 id,
195 resourceId: root.rootId,
196 olds: root.tags,
197 news: news.tags,
198 });
199
200 yield* session.note(root.rootArn);
201 return {
202 ...root,
203 tags,
204 };
205 }),
206 delete: Effect.fn(function* () {}),
207 };
208 }),
209 );
210
211const listRoots = () =>
212 collectPages(
213 (NextToken) => organizations.listRoots({ NextToken }),
214 (page) => page.Roots,
215 );
216
217const readRoot = Effect.fn(function* ({
218 rootId,
219 name,
220}: {
221 rootId?: string;
222 name?: string;
223}) {
224 const roots = yield* retryOrganizations(listRoots());
225 const root = roots.find(
226 (candidate) =>
227 (rootId ? candidate.Id === rootId : true) &&
228 (name ? candidate.Name === name : true),
229 );
230
231 if (!root?.Id || !root.Arn || !root.Name) {
232 return undefined;
233 }
234
235 const tags = yield* readResourceTags(root.Id).pipe(
236 Effect.catchTag("TargetNotFoundException", () => Effect.succeed({})),
237 );
238
239 return {
240 rootId: root.Id,
241 rootArn: root.Arn,
242 rootName: root.Name,
243 policyTypes: root.PolicyTypes ?? [],
244 tags,
245 } satisfies Root["Attributes"];
246});