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 EnabledControlProps {
19 /**
20 * The identifier (ARN) of the control to enable, e.g.
21 * `arn:aws:controltower:us-west-2::control/AWS-GR_ENCRYPTED_VOLUMES` or a
22 * Control Catalog ARN. Changing the control replaces the enablement.
23 */
24 controlIdentifier: string;
25 /**
26 * The ARN of the organizational unit the control is enabled on.
27 * Changing the target replaces the enablement.
28 */
29 targetIdentifier: string;
30 /**
31 * Parameters for configurable controls, as `{ key, value }` pairs.
32 * Updated in place via `UpdateEnabledControl`.
33 */
34 parameters?: { key: string; value: any }[];
35 /**
36 * Tags to apply to the enabled control. Merged with internal Alchemy
37 * tags.
38 */
39 tags?: Record<string, string>;
40}
41
42export interface EnabledControl extends Resource<
43 "AWS.ControlTower.EnabledControl",
44 EnabledControlProps,
45 {
46 /**
47 * The ARN of the enabled control.
48 */
49 enabledControlArn: string;
50 /**
51 * The identifier of the enabled control.
52 */
53 controlIdentifier: string;
54 /**
55 * The ARN of the organizational unit the control is enabled on.
56 */
57 targetIdentifier: string;
58 },
59 never,
60 Providers
61> {}
62
63/**
64 * An AWS Control Tower control (guardrail) enabled on an organizational
65 * unit. Enabling a control starts an asynchronous operation that deploys
66 * governance resources (SCPs, Config rules, or hooks) to the OU and the
67 * accounts it contains.
68 *
69 * Requires an AWS Control Tower landing zone and can only be managed from
70 * the Organizations management account.
71 * ### Enabling Controls
72 * **Example:** Enable a preventive guardrail on an OU
73 * ```typescript
74 * import * as ControlTower from "alchemy/AWS/ControlTower";
75 *
76 * const encryptedVolumes = yield* ControlTower.EnabledControl("EncryptedVolumes", {
77 * controlIdentifier:
78 * "arn:aws:controltower:us-west-2::control/AWS-GR_ENCRYPTED_VOLUMES",
79 * targetIdentifier: "arn:aws:organizations::111122223333:ou/o-example/ou-example",
80 * });
81 * ```
82 *
83 * **Example:** Enable a configurable control with parameters
84 * ```typescript
85 * const regionDeny = yield* ControlTower.EnabledControl("RegionDeny", {
86 * controlIdentifier:
87 * "arn:aws:controlcatalog:::control/50utmyu7yqmhr8fpnpo8bnaj1",
88 * targetIdentifier: ouArn,
89 * parameters: [
90 * { key: "AllowedRegions", value: ["us-east-1", "us-west-2"] },
91 * ],
92 * });
93 * ```
94 *
95 * @resource
96 */
97export const EnabledControl = Resource<EnabledControl>(
98 "AWS.ControlTower.EnabledControl",
99);
100
101/**
102 * An asynchronous control operation (ENABLE_CONTROL / DISABLE_CONTROL /
103 * UPDATE_ENABLED_CONTROL) converged to the terminal `FAILED` status.
104 */
105export class ControlOperationFailed extends Data.TaggedError(
106 "ControlOperationFailed",
107)<{
108 readonly operationIdentifier: string;
109 readonly status: string;
110 readonly statusMessage: string | undefined;
111}> {}
112
113/**
114 * `EnableControl` succeeded but the enabled control's ARN could not be
115 * resolved from either the operation output or `ListEnabledControls`.
116 */
117export class EnabledControlArnUnavailable extends Data.TaggedError(
118 "EnabledControlArnUnavailable",
119)<{
120 readonly controlIdentifier: string;
121 readonly targetIdentifier: string;
122}> {}
123
124/**
125 * Internal signal that a control operation is still `IN_PROGRESS`,
126 * consumed by {@link waitForControlOperation}'s bounded schedule.
127 */
128class ControlOperationPending extends Data.TaggedError(
129 "ControlOperationPending",
130)<{
131 readonly operationIdentifier: string;
132 readonly status: string | undefined;
133}> {}
134
135// Explicitly-typed retry wrappers — an inline `Effect.retry` in provider
136// lifecycle code leaks `Retry.Return`'s conditional type into declaration
137// emit and widens the provider layer to `unknown` for every consumer of
138// `AWS.providers()`.
139const retryWhileControlOperationPending = <
140 A,
141 E extends { readonly _tag: string },
142 R,
143>(
144 self: Effect.Effect<A, E, R>,
145): Effect.Effect<A, E, R> =>
146 Effect.retry(self, {
147 while: (e) => e._tag === "ControlOperationPending",
148 // Control operations deploy SCPs/Config rules across an OU's accounts
149 // and typically take a few minutes; poll every 10s up to ~15 minutes.
150 schedule: Schedule.max([
151 Schedule.spaced("10 seconds"),
152 Schedule.recurs(90),
153 ]),
154 });
155
156// Control Tower serializes control operations per OU — a concurrent
157// operation surfaces as a typed ConflictException. Retry through it on a
158// bounded schedule.
159const retryWhileControlConflict = <A, E extends { readonly _tag: string }, R>(
160 self: Effect.Effect<A, E, R>,
161): Effect.Effect<A, E, R> =>
162 Effect.retry(self, {
163 while: (e) => e._tag === "ConflictException",
164 schedule: Schedule.max([
165 Schedule.spaced("15 seconds"),
166 Schedule.recurs(40),
167 ]),
168 });
169
170const waitForControlOperation = (operationIdentifier: string) =>
171 retryWhileControlOperationPending(
172 Effect.gen(function* () {
173 const { controlOperation } = yield* controltower.getControlOperation({
174 operationIdentifier,
175 });
176 if (controlOperation.status === "SUCCEEDED") {
177 return;
178 }
179 if (controlOperation.status === "FAILED") {
180 return yield* Effect.fail(
181 new ControlOperationFailed({
182 operationIdentifier,
183 status: controlOperation.status,
184 statusMessage: controlOperation.statusMessage,
185 }),
186 );
187 }
188 return yield* Effect.fail(
189 new ControlOperationPending({
190 operationIdentifier,
191 status: controlOperation.status,
192 }),
193 );
194 }),
195 );
196
197const readEnabledControl = (enabledControlArn: string) =>
198 controltower
199 .getEnabledControl({ enabledControlIdentifier: enabledControlArn })
200 .pipe(
201 Effect.map((r) => r.enabledControlDetails),
202 Effect.catchTag("ResourceNotFoundException", () =>
203 Effect.succeed(undefined),
204 ),
205 );
206
207// Find an enabled control by (controlIdentifier, targetIdentifier) —
208// covers the create-race and lost-output cases where we don't have the
209// ARN cached.
210const findEnabledControlArn = (
211 controlIdentifier: string,
212 targetIdentifier: string,
213) =>
214 controltower.listEnabledControls.pages({ targetIdentifier }).pipe(
215 Stream.runCollect,
216 Effect.map(
217 (chunk) =>
218 Array.from(chunk)
219 .flatMap((page) => page.enabledControls)
220 .find((summary) => summary.controlIdentifier === controlIdentifier)
221 ?.arn,
222 ),
223 Effect.catchTag("ResourceNotFoundException", () =>
224 Effect.succeed(undefined),
225 ),
226 );
227
228export const EnabledControlProvider = () =>
229 Provider.effect(
230 EnabledControl,
231 Effect.gen(function* () {
232 return EnabledControl.Provider.of({
233 stables: ["enabledControlArn", "controlIdentifier", "targetIdentifier"],
234 // Enumeration requires a target OU — there is no org-wide listing
235 // that returns the full attribute shape without the management
236 // account's OU tree, so nuke discovery is not supported.
237 list: () => Effect.succeed([]),
238 read: Effect.fn(function* ({ id, olds, output }) {
239 const arn =
240 output?.enabledControlArn ??
241 (olds !== undefined
242 ? yield* findEnabledControlArn(
243 olds.controlIdentifier,
244 olds.targetIdentifier,
245 )
246 : undefined);
247 if (arn === undefined) return undefined;
248 const details = yield* readEnabledControl(arn);
249 if (details === undefined) return undefined;
250 const attrs = {
251 enabledControlArn: arn,
252 controlIdentifier:
253 details.controlIdentifier ?? olds?.controlIdentifier ?? "",
254 targetIdentifier:
255 details.targetIdentifier ?? olds?.targetIdentifier ?? "",
256 };
257 const tags = yield* observeControlTowerTags(arn);
258 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
259 }),
260 diff: Effect.fn(function* ({ news, olds }) {
261 if (!isResolved(news)) return undefined;
262 if (
263 news.controlIdentifier !== olds.controlIdentifier ||
264 news.targetIdentifier !== olds.targetIdentifier
265 ) {
266 return { action: "replace" } as const;
267 }
268 }),
269 reconcile: Effect.fn(function* ({ id, news, output, session }) {
270 // 1. Observe — cloud state is authoritative; output is only an
271 // ARN cache.
272 let arn = output?.enabledControlArn;
273 let details =
274 arn === undefined ? undefined : yield* readEnabledControl(arn);
275 if (details === undefined) {
276 arn = yield* findEnabledControlArn(
277 news.controlIdentifier,
278 news.targetIdentifier,
279 );
280 details =
281 arn === undefined ? undefined : yield* readEnabledControl(arn);
282 }
283
284 // 2. Ensure — enable if missing and wait for the asynchronous
285 // operation to converge.
286 if (details === undefined) {
287 const internalTags = yield* createInternalTags(id);
288 const enabled = yield* retryWhileControlConflict(
289 controltower.enableControl({
290 controlIdentifier: news.controlIdentifier,
291 targetIdentifier: news.targetIdentifier,
292 parameters: news.parameters,
293 tags: { ...news.tags, ...internalTags },
294 }),
295 );
296 yield* session.note(
297 `control operation ${enabled.operationIdentifier}`,
298 );
299 yield* waitForControlOperation(enabled.operationIdentifier);
300 arn =
301 enabled.arn ??
302 (yield* findEnabledControlArn(
303 news.controlIdentifier,
304 news.targetIdentifier,
305 ));
306 if (arn === undefined) {
307 return yield* Effect.fail(
308 new EnabledControlArnUnavailable({
309 controlIdentifier: news.controlIdentifier,
310 targetIdentifier: news.targetIdentifier,
311 }),
312 );
313 }
314 details = yield* readEnabledControl(arn);
315 } else if (news.parameters !== undefined) {
316 // 3. Sync — parameters are the only in-place-mutable aspect.
317 // Diff observed parameter summaries against desired; skip
318 // the API on a no-op. Parameters cannot be cleared (the
319 // update API requires them), so an absent `parameters` prop
320 // leaves observed parameters alone.
321 const observed = canonicalParameters(details.parameters);
322 const desired = canonicalParameters(news.parameters);
323 if (observed !== desired) {
324 const updated = yield* retryWhileControlConflict(
325 controltower.updateEnabledControl({
326 enabledControlIdentifier: arn!,
327 parameters: news.parameters,
328 }),
329 );
330 yield* session.note(
331 `control operation ${updated.operationIdentifier}`,
332 );
333 yield* waitForControlOperation(updated.operationIdentifier);
334 }
335 }
336
337 // 3b. Sync tags against observed cloud tags.
338 yield* syncControlTowerTags(arn!, id, news.tags);
339
340 // 4. Return fresh attributes.
341 return {
342 enabledControlArn: arn!,
343 controlIdentifier: news.controlIdentifier,
344 targetIdentifier: news.targetIdentifier,
345 };
346 }),
347 delete: Effect.fn(function* ({ output, session }) {
348 const result = yield* retryWhileControlConflict(
349 controltower.disableControl({
350 enabledControlIdentifier: output.enabledControlArn,
351 }),
352 ).pipe(
353 Effect.catchTag("ResourceNotFoundException", () =>
354 Effect.succeed(undefined),
355 ),
356 );
357 if (result !== undefined) {
358 yield* session.note(
359 `control operation ${result.operationIdentifier}`,
360 );
361 yield* waitForControlOperation(result.operationIdentifier);
362 }
363 }),
364 });
365 }),
366 );