1import * as accountManagement from "@distilled.cloud/aws/account";
2import * as organizations from "@distilled.cloud/aws/organizations";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { hasAlchemyTags } from "../../Tags.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 collectPages,
13 readResourceTags,
14 retryOrganizations,
15 unredact,
16 updateResourceTags,
17} from "./common.ts";
18
19export type AccountId = string;
20export type AccountArn = string;
21
22export interface AccountProps {
23 /**
24 * Account email. Must be globally unique across AWS accounts.
25 */
26 email: string;
27 /**
28 * Friendly account name.
29 */
30 name: string;
31 /**
32 * Parent root or OU ID.
33 */
34 parentId: string;
35 /**
36 * Optional cross-account access role name created during account vending.
37 */
38 roleName?: string;
39 /**
40 * Whether IAM users can access billing information.
41 */
42 iamUserAccessToBilling?: organizations.IAMUserAccessToBilling;
43 /**
44 * Optional tags applied to the member account while it remains in the org.
45 */
46 tags?: Record<string, string>;
47}
48
49export interface Account extends Resource<
50 "AWS.Organizations.Account",
51 AccountProps,
52 {
53 /**
54 * 12-digit AWS account ID.
55 */
56 accountId: AccountId;
57 /**
58 * ARN of the member account.
59 */
60 accountArn: AccountArn;
61 /**
62 * Friendly account name.
63 */
64 name: string | undefined;
65 /**
66 * Email address associated with the account.
67 */
68 email: string | undefined;
69 /**
70 * ID of the parent root or OU.
71 */
72 parentId: string | undefined;
73 /**
74 * Account status (`ACTIVE`, `SUSPENDED`, or `PENDING_CLOSURE`).
75 */
76 status: organizations.AccountStatus | undefined;
77 /**
78 * Account state reported by AWS Organizations.
79 */
80 state: organizations.AccountState | undefined;
81 /**
82 * How the account joined the organization (`CREATED` or `INVITED`).
83 */
84 joinedMethod: organizations.AccountJoinedMethod | undefined;
85 /**
86 * When the account joined the organization.
87 */
88 joinedTimestamp: Date | undefined;
89 /**
90 * Tags on the member account.
91 */
92 tags: Record<string, string>;
93 },
94 never,
95 Providers
96> {}
97
98/**
99 * A member account created and managed by AWS Organizations.
100 *
101 * Account creation is asynchronous — the provider polls the vending request
102 * until the account ID is assigned. Must be deployed from the organization's
103 * management account. Changing `email` replaces the account; changing `name`
104 * updates it in place.
105 * ### Creating Member Accounts
106 * **Example:** Account Under the Organization Root
107 * ```typescript
108 * const root = yield* Root("Root", {});
109 *
110 * const dev = yield* Account("Dev", {
111 * name: "dev",
112 * email: "aws-dev@example.com",
113 * parentId: root.rootId,
114 * });
115 * ```
116 *
117 * **Example:** Account Inside an Organizational Unit
118 * ```typescript
119 * const workloads = yield* OrganizationalUnit("Workloads", {
120 * parentId: root.rootId,
121 * name: "workloads",
122 * });
123 *
124 * const prod = yield* Account("Prod", {
125 * name: "prod",
126 * email: "aws-prod@example.com",
127 * parentId: workloads.ouId,
128 * roleName: "OrganizationAccountAccessRole",
129 * tags: { environment: "prod" },
130 * });
131 * ```
132 *
133 * @resource
134 */
135export const Account = Resource<Account>("AWS.Organizations.Account");
136
137export const AccountProvider = () =>
138 Provider.effect(
139 Account,
140 Effect.gen(function* () {
141 return {
142 stables: ["accountId", "accountArn", "joinedMethod", "joinedTimestamp"],
143 diff: Effect.fn(function* ({ olds, news }) {
144 if (!isResolved(news)) return;
145 if (olds?.email !== news.email) {
146 return { action: "replace" } as const;
147 }
148 if (olds?.name !== news.name) {
149 return { action: "update" } as const;
150 }
151 }),
152 read: Effect.fn(function* ({ id, olds, output }) {
153 const state = output?.accountId
154 ? yield* readAccountById(output.accountId)
155 : olds
156 ? yield* readAccountByNameOrEmail({
157 name: olds.name,
158 email: olds.email,
159 })
160 : undefined;
161 if (!state) return undefined;
162 return (yield* hasAlchemyTags(id, state.tags))
163 ? state
164 : Unowned(state);
165 }),
166 reconcile: Effect.fn(function* ({ id, news, output, session }) {
167 // Observe — locate the account by ID if known, else by
168 // name/email. We always fetch fresh: state persistence may have
169 // failed after a prior `createAccount`, leaving an account on AWS
170 // we have no record of.
171 let state = output?.accountId
172 ? yield* readAccountById(output.accountId)
173 : yield* readAccountByNameOrEmail({
174 name: news.name,
175 email: news.email,
176 });
177
178 // Ensure — create the account if it doesn't exist. Account
179 // creation is asynchronous: `createAccount` returns a
180 // `CreateAccountStatus` which we poll until the account ID is
181 // assigned (or the request fails).
182 if (!state) {
183 const createResponse = yield* retryOrganizations(
184 organizations.createAccount({
185 Email: news.email,
186 AccountName: news.name,
187 RoleName: news.roleName,
188 IamUserAccessToBilling: news.iamUserAccessToBilling,
189 }),
190 );
191
192 const requestId = createResponse.CreateAccountStatus?.Id;
193 if (requestId) {
194 const status = yield* waitForCreateAccount(requestId);
195 yield* session.note(status.AccountId ?? requestId);
196 state = status.AccountId
197 ? yield* readAccountById(status.AccountId)
198 : yield* readAccountByNameOrEmail({
199 name: news.name,
200 email: news.email,
201 });
202 } else {
203 state = yield* readAccountByNameOrEmail({
204 name: news.name,
205 email: news.email,
206 });
207 }
208 if (!state) {
209 return yield* Effect.fail(
210 new Error(`account '${news.name}' not found after create`),
211 );
212 }
213 }
214
215 // Sync name — observed ↔ desired. Account name lives on the
216 // account-management service, not Organizations. The diff has
217 // already short-circuited any email change as a replacement.
218 if (state.name !== news.name) {
219 yield* retryAccountManagement(
220 accountManagement.putAccountName({
221 AccountId: state.accountId,
222 AccountName: news.name,
223 }),
224 );
225 }
226
227 // Sync parent — move the account if its observed parent differs
228 // from desired. We only move when we know the source parent (the
229 // API requires it).
230 if (state.parentId && state.parentId !== news.parentId) {
231 yield* retryOrganizations(
232 organizations.moveAccount({
233 AccountId: state.accountId,
234 SourceParentId: state.parentId,
235 DestinationParentId: news.parentId,
236 }),
237 );
238 }
239
240 // Sync tags — diff observed cloud tags against desired so
241 // adoption and drift converge. We baseline against `state.tags`
242 // (fetched fresh) instead of stale `olds`.
243 const tags = yield* updateResourceTags({
244 id,
245 resourceId: state.accountId,
246 olds: state.tags,
247 news: news.tags,
248 });
249
250 const updated = yield* readAccountById(state.accountId);
251 if (!updated) {
252 return yield* Effect.fail(
253 new Error(
254 `account '${state.accountId}' not found after reconcile`,
255 ),
256 );
257 }
258
259 yield* session.note(updated.accountArn);
260 return {
261 ...updated,
262 tags,
263 };
264 }),
265 delete: Effect.fn(function* ({ output }) {
266 yield* retryOrganizations(
267 organizations
268 .removeAccountFromOrganization({
269 AccountId: output.accountId,
270 })
271 .pipe(
272 Effect.catchTags({
273 AccountNotFoundException: () => Effect.void,
274 AWSOrganizationsNotInUseException: () => Effect.void,
275 }),
276 ),
277 );
278 }),
279 // Enumerate every account in the organization (paginated), then
280 // hydrate each into the exact `read` Attributes shape (parent + tags)
281 // with bounded concurrency. Per-item not-found is already typed and
282 // swallowed inside `readAccountById`. When the caller's account is not
283 // the management account of an organization there is nothing to
284 // enumerate, so we return an empty array rather than throwing.
285 list: () =>
286 Effect.gen(function* () {
287 const accounts = yield* listAccounts();
288 const rows = yield* Effect.forEach(
289 accounts,
290 (account) =>
291 account.Id
292 ? readAccountById(account.Id)
293 : Effect.succeed(undefined),
294 { concurrency: 10 },
295 );
296 return rows.filter(
297 (row): row is Account["Attributes"] => row !== undefined,
298 );
299 }).pipe(
300 Effect.catchTag("AWSOrganizationsNotInUseException", () =>
301 Effect.succeed([] as Account["Attributes"][]),
302 ),
303 ),
304 };
305 }),
306 );
307
308const listAccounts = () =>
309 collectPages(
310 (NextToken) => organizations.listAccounts({ NextToken }),
311 (page) => page.Accounts,
312 ).pipe(retryOrganizations);
313
314const readParentId = (childId: string) =>
315 collectPages(
316 (NextToken) => organizations.listParents({ ChildId: childId, NextToken }),
317 (page) => page.Parents,
318 ).pipe(
319 retryOrganizations,
320 Effect.map((parents) => parents[0]?.Id),
321 Effect.catchTag("ChildNotFoundException", () => Effect.succeed(undefined)),
322 );
323
324const readAccountById = Effect.fn(function* (accountId: string) {
325 const described = yield* retryOrganizations(
326 organizations.describeAccount({ AccountId: accountId }).pipe(
327 Effect.map((response) => response.Account),
328 Effect.catchTag("AccountNotFoundException", () =>
329 Effect.succeed(undefined),
330 ),
331 ),
332 );
333
334 if (!described?.Id || !described.Arn) {
335 return undefined;
336 }
337
338 const [parentId, tags] = yield* Effect.all([
339 readParentId(described.Id),
340 readResourceTags(described.Id).pipe(
341 Effect.catchTag("TargetNotFoundException", () => Effect.succeed({})),
342 ),
343 ]);
344
345 return {
346 accountId: described.Id,
347 accountArn: described.Arn,
348 name: unredact(described.Name),
349 email: unredact(described.Email),
350 parentId,
351 status: described.Status,
352 state: described.State,
353 joinedMethod: described.JoinedMethod,
354 joinedTimestamp: described.JoinedTimestamp,
355 tags,
356 } satisfies Account["Attributes"];
357});
358
359const readAccountByNameOrEmail = Effect.fn(function* ({
360 name,
361 email,
362}: Pick<AccountProps, "name" | "email">) {
363 const accounts = yield* listAccounts();
364 const match = accounts.find(
365 (candidate) =>
366 unredact(candidate.Name) === name || unredact(candidate.Email) === email,
367 );
368 return match?.Id ? yield* readAccountById(match.Id) : undefined;
369});
370
371const waitForCreateAccount = (requestId: string) =>
372 Effect.gen(function* () {
373 const status = yield* retryOrganizations(
374 organizations
375 .describeCreateAccountStatus({
376 CreateAccountRequestId: requestId,
377 })
378 .pipe(Effect.map((response) => response.CreateAccountStatus)),
379 );
380
381 if (!status?.State || status.State === "IN_PROGRESS") {
382 return yield* Effect.fail({ _tag: "CreateAccountInProgress" as const });
383 }
384
385 if (status.State === "FAILED") {
386 return yield* Effect.fail(
387 new Error(
388 `account creation failed: ${status.FailureReason ?? "unknown failure"}`,
389 ),
390 );
391 }
392
393 if (!status.AccountId) {
394 return yield* Effect.fail(
395 new Error("account creation succeeded without AccountId"),
396 );
397 }
398
399 return status;
400 }).pipe(
401 Effect.retry({
402 while: (error: any) => error?._tag === "CreateAccountInProgress",
403 schedule: Schedule.max([
404 Schedule.spaced("2 seconds"),
405 Schedule.recurs(120),
406 ]),
407 }),
408 );
409
410const retryAccountManagement = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
411 effect.pipe(
412 Effect.retry({
413 while: (error: any) =>
414 error?._tag === "TooManyRequestsException" ||
415 error?._tag === "InternalServerException",
416 schedule: Schedule.max([Schedule.exponential(200), Schedule.recurs(8)]),
417 }),
418 );