1import * as controltower from "@distilled.cloud/aws/controltower";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 canonicalParameters,
14 observeControlTowerTags,
15 syncControlTowerTags,
16} from "./internal.ts";
17
18export interface EnabledBaselineProps {
19 /**
20 * The ARN of the baseline to enable, e.g. the `AWSControlTowerBaseline`
21 * ARN from `ListBaselines`. Changing the baseline replaces the
22 * enablement.
23 */
24 baselineIdentifier: string;
25 /**
26 * The baseline version to enable, e.g. `"4.0"`. Updated in place via
27 * `UpdateEnabledBaseline`.
28 */
29 baselineVersion: string;
30 /**
31 * The ARN of the target (organizational unit) the baseline is enabled
32 * on. Changing the target replaces the enablement.
33 */
34 targetIdentifier: string;
35 /**
36 * Parameters applied to the baseline, as `{ key, value }` pairs — e.g.
37 * `IdentityCenterEnabledBaselineArn` for the `AWSControlTowerBaseline`.
38 * Updated in place via `UpdateEnabledBaseline`.
39 */
40 parameters?: { key: string; value: any }[];
41 /**
42 * Tags to apply to the enabled baseline. Merged with internal Alchemy
43 * tags.
44 */
45 tags?: Record<string, string>;
46}
47
48export interface EnabledBaseline extends Resource<
49 "AWS.ControlTower.EnabledBaseline",
50 EnabledBaselineProps,
51 {
52 /**
53 * The ARN of the enabled baseline (the `EnabledBaseline` resource).
54 */
55 enabledBaselineArn: string;
56 /**
57 * The ARN of the baseline that was enabled.
58 */
59 baselineIdentifier: string;
60 /**
61 * The ARN of the target organizational unit.
62 */
63 targetIdentifier: string;
64 /**
65 * The enabled baseline version.
66 */
67 baselineVersion: string | undefined;
68 },
69 never,
70 Providers
71> {}
72
73/**
74 * An AWS Control Tower baseline enabled on a target organizational unit.
75 * Enabling a baseline (e.g. `AWSControlTowerBaseline`) starts an
76 * asynchronous operation that registers the OU with Control Tower and
77 * deploys the baseline's governance resources to its accounts.
78 *
79 * Requires an AWS Control Tower landing zone and can only be managed from
80 * the Organizations management account.
81 * ### Enabling Baselines
82 * **Example:** Register an OU with Control Tower
83 * ```typescript
84 * import * as ControlTower from "alchemy/AWS/ControlTower";
85 *
86 * const enabled = yield* ControlTower.EnabledBaseline("WorkloadsBaseline", {
87 * baselineIdentifier:
88 * "arn:aws:controltower:us-west-2::baseline/17BSJV3IGJ2QSGA2",
89 * baselineVersion: "4.0",
90 * targetIdentifier: "arn:aws:organizations::111122223333:ou/o-example/ou-example",
91 * });
92 * ```
93 *
94 * **Example:** Baseline with Identity Center parameter
95 * ```typescript
96 * const enabled = yield* ControlTower.EnabledBaseline("WorkloadsBaseline", {
97 * baselineIdentifier: controlTowerBaselineArn,
98 * baselineVersion: "4.0",
99 * targetIdentifier: ouArn,
100 * parameters: [
101 * {
102 * key: "IdentityCenterEnabledBaselineArn",
103 * value: identityCenterEnabledBaselineArn,
104 * },
105 * ],
106 * });
107 * ```
108 *
109 * @resource
110 */
111export const EnabledBaseline = Resource<EnabledBaseline>(
112 "AWS.ControlTower.EnabledBaseline",
113);
114
115/**
116 * An asynchronous baseline operation (ENABLE_BASELINE / DISABLE_BASELINE /
117 * UPDATE_ENABLED_BASELINE) converged to the terminal `FAILED` status.
118 */
119export class BaselineOperationFailed extends Data.TaggedError(
120 "BaselineOperationFailed",
121)<{
122 readonly operationIdentifier: string;
123 readonly status: string;
124 readonly statusMessage: string | undefined;
125}> {}
126
127/**
128 * Internal signal that a baseline operation is still `IN_PROGRESS`,
129 * consumed by {@link waitForBaselineOperation}'s bounded schedule.
130 */
131class BaselineOperationPending extends Data.TaggedError(
132 "BaselineOperationPending",
133)<{
134 readonly operationIdentifier: string;
135 readonly status: string | undefined;
136}> {}
137
138// Explicitly-typed retry wrappers — an inline `Effect.retry` in provider
139// lifecycle code leaks `Retry.Return`'s conditional type into declaration
140// emit and widens the provider layer to `unknown` for every consumer of
141// `AWS.providers()`.
142const retryWhileBaselineOperationPending = <
143 A,
144 E extends { readonly _tag: string },
145 R,
146>(
147 self: Effect.Effect<A, E, R>,
148): Effect.Effect<A, E, R> =>
149 Effect.retry(self, {
150 while: (e) => e._tag === "BaselineOperationPending",
151 // Baseline enablement provisions accounts and typically takes several
152 // minutes; poll every 10s up to ~20 minutes.
153 schedule: Schedule.max([
154 Schedule.spaced("10 seconds"),
155 Schedule.recurs(120),
156 ]),
157 });
158
159// Control Tower serializes baseline operations per target — a concurrent
160// operation surfaces as a typed ConflictException. Retry through it on a
161// bounded schedule.
162const retryWhileBaselineConflict = <A, E extends { readonly _tag: string }, R>(
163 self: Effect.Effect<A, E, R>,
164): Effect.Effect<A, E, R> =>
165 Effect.retry(self, {
166 while: (e) => e._tag === "ConflictException",
167 schedule: Schedule.max([
168 Schedule.spaced("15 seconds"),
169 Schedule.recurs(40),
170 ]),
171 });
172
173const waitForBaselineOperation = (operationIdentifier: string) =>
174 retryWhileBaselineOperationPending(
175 Effect.gen(function* () {
176 const { baselineOperation } = yield* controltower.getBaselineOperation({
177 operationIdentifier,
178 });
179 if (baselineOperation.status === "SUCCEEDED") {
180 return;
181 }
182 if (baselineOperation.status === "FAILED") {
183 return yield* Effect.fail(
184 new BaselineOperationFailed({
185 operationIdentifier,
186 status: baselineOperation.status,
187 statusMessage: baselineOperation.statusMessage,
188 }),
189 );
190 }
191 return yield* Effect.fail(
192 new BaselineOperationPending({
193 operationIdentifier,
194 status: baselineOperation.status,
195 }),
196 );
197 }),
198 );
199
200const readEnabledBaseline = (enabledBaselineArn: string) =>
201 controltower
202 .getEnabledBaseline({ enabledBaselineIdentifier: enabledBaselineArn })
203 .pipe(
204 Effect.map((r) => r.enabledBaselineDetails),
205 Effect.catchTag("ResourceNotFoundException", () =>
206 Effect.succeed(undefined),
207 ),
208 );
209
210// Find an enabled baseline by (baselineIdentifier, targetIdentifier) —
211// covers the create-race and lost-output cases where we don't have the
212// ARN cached.
213const findEnabledBaselineArn = (
214 baselineIdentifier: string,
215 targetIdentifier: string,
216) =>
217 controltower.listEnabledBaselines
218 .pages({
219 filter: {
220 baselineIdentifiers: [baselineIdentifier],
221 targetIdentifiers: [targetIdentifier],
222 },
223 })
224 .pipe(
225 Stream.runCollect,
226 Effect.map(
227 (chunk) =>
228 Array.from(chunk).flatMap((page) => page.enabledBaselines)[0]?.arn,
229 ),
230 );
231
232const toAttributes = (
233 arn: string,
234 details: controltower.EnabledBaselineDetails | undefined,
235 fallback: {
236 baselineIdentifier: string;
237 targetIdentifier: string;
238 baselineVersion?: string;
239 },
240): EnabledBaseline["Attributes"] => ({
241 enabledBaselineArn: arn,
242 baselineIdentifier:
243 details?.baselineIdentifier ?? fallback.baselineIdentifier,
244 targetIdentifier: details?.targetIdentifier ?? fallback.targetIdentifier,
245 baselineVersion: details?.baselineVersion ?? fallback.baselineVersion,
246});
247
248export const EnabledBaselineProvider = () =>
249 Provider.effect(
250 EnabledBaseline,
251 Effect.gen(function* () {
252 return EnabledBaseline.Provider.of({
253 stables: [
254 "enabledBaselineArn",
255 "baselineIdentifier",
256 "targetIdentifier",
257 ],
258 // Enumerate every enabled baseline in the ambient account (the
259 // filter-less listing is supported on management accounts; on
260 // non-Control-Tower accounts there is simply nothing to list).
261 list: () =>
262 controltower.listEnabledBaselines.pages({}).pipe(
263 Stream.runCollect,
264 Effect.map((chunk) =>
265 Array.from(chunk)
266 .flatMap((page) => page.enabledBaselines)
267 .map((summary) => ({
268 enabledBaselineArn: summary.arn,
269 baselineIdentifier: summary.baselineIdentifier,
270 targetIdentifier: summary.targetIdentifier,
271 baselineVersion: summary.baselineVersion,
272 })),
273 ),
274 ),
275 read: Effect.fn(function* ({ id, olds, output }) {
276 const arn =
277 output?.enabledBaselineArn ??
278 (olds !== undefined
279 ? yield* findEnabledBaselineArn(
280 olds.baselineIdentifier,
281 olds.targetIdentifier,
282 )
283 : undefined);
284 if (arn === undefined) return undefined;
285 const details = yield* readEnabledBaseline(arn);
286 if (details === undefined) return undefined;
287 const attrs = toAttributes(arn, details, {
288 baselineIdentifier: olds?.baselineIdentifier ?? "",
289 targetIdentifier: olds?.targetIdentifier ?? "",
290 });
291 const tags = yield* observeControlTowerTags(arn);
292 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
293 }),
294 diff: Effect.fn(function* ({ news, olds }) {
295 if (!isResolved(news)) return undefined;
296 if (
297 news.baselineIdentifier !== olds.baselineIdentifier ||
298 news.targetIdentifier !== olds.targetIdentifier
299 ) {
300 return { action: "replace" } as const;
301 }
302 }),
303 reconcile: Effect.fn(function* ({ id, news, output, session }) {
304 // 1. Observe — cloud state is authoritative; output is only an
305 // ARN cache.
306 let arn = output?.enabledBaselineArn;
307 let details =
308 arn === undefined ? undefined : yield* readEnabledBaseline(arn);
309 if (details === undefined) {
310 arn = yield* findEnabledBaselineArn(
311 news.baselineIdentifier,
312 news.targetIdentifier,
313 );
314 details =
315 arn === undefined ? undefined : yield* readEnabledBaseline(arn);
316 }
317
318 // 2. Ensure — enable if missing and wait for the asynchronous
319 // operation to converge.
320 if (details === undefined || arn === undefined) {
321 const internalTags = yield* createInternalTags(id);
322 const enabled = yield* retryWhileBaselineConflict(
323 controltower.enableBaseline({
324 baselineIdentifier: news.baselineIdentifier,
325 baselineVersion: news.baselineVersion,
326 targetIdentifier: news.targetIdentifier,
327 parameters: news.parameters,
328 tags: { ...news.tags, ...internalTags },
329 }),
330 );
331 arn = enabled.arn;
332 yield* session.note(
333 `baseline operation ${enabled.operationIdentifier}`,
334 );
335 yield* waitForBaselineOperation(enabled.operationIdentifier);
336 details = yield* readEnabledBaseline(arn);
337 } else {
338 // 3. Sync — version and parameters are updated in place. Diff
339 // observed state against desired; skip the API on a no-op.
340 // Parameters cannot be cleared (only replaced), so an
341 // absent `parameters` prop leaves observed parameters
342 // alone.
343 const versionChanged =
344 details.baselineVersion !== news.baselineVersion;
345 const parametersChanged =
346 news.parameters !== undefined &&
347 canonicalParameters(details.parameters) !==
348 canonicalParameters(news.parameters);
349 if (versionChanged || parametersChanged) {
350 const updated = yield* retryWhileBaselineConflict(
351 controltower.updateEnabledBaseline({
352 enabledBaselineIdentifier: arn,
353 baselineVersion: news.baselineVersion,
354 parameters: news.parameters,
355 }),
356 );
357 yield* session.note(
358 `baseline operation ${updated.operationIdentifier}`,
359 );
360 yield* waitForBaselineOperation(updated.operationIdentifier);
361 details = yield* readEnabledBaseline(arn);
362 }
363 }
364
365 // 3b. Sync tags against observed cloud tags.
366 yield* syncControlTowerTags(arn, id, news.tags);
367
368 // 4. Return fresh attributes.
369 return toAttributes(arn, details, {
370 baselineIdentifier: news.baselineIdentifier,
371 targetIdentifier: news.targetIdentifier,
372 baselineVersion: news.baselineVersion,
373 });
374 }),
375 delete: Effect.fn(function* ({ output, session }) {
376 const result = yield* retryWhileBaselineConflict(
377 controltower.disableBaseline({
378 enabledBaselineIdentifier: output.enabledBaselineArn,
379 }),
380 ).pipe(
381 Effect.catchTag("ResourceNotFoundException", () =>
382 Effect.succeed(undefined),
383 ),
384 );
385 if (result !== undefined) {
386 yield* session.note(
387 `baseline operation ${result.operationIdentifier}`,
388 );
389 yield* waitForBaselineOperation(result.operationIdentifier);
390 }
391 }),
392 });
393 }),
394 );