1import * as budgets from "@distilled.cloud/aws/budgets";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, diffTags } from "../../Tags.ts";
10import { AWSEnvironment } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12import type { BudgetSubscriber } from "./Budget.ts";
13
14/**
15 * The threshold that triggers a budget action.
16 */
17export interface BudgetActionThreshold {
18 /**
19 * The threshold value. Interpreted as a percentage of the budget limit by
20 * default, or an absolute amount when `actionThresholdType` is
21 * `ABSOLUTE_VALUE`.
22 */
23 actionThresholdValue: number;
24 /**
25 * Whether the threshold is a percentage of the budget or an absolute value.
26 */
27 actionThresholdType: "PERCENTAGE" | "ABSOLUTE_VALUE" | (string & {});
28}
29
30/**
31 * What the action does when it fires — exactly one of the three definitions
32 * must be set, matching the action's `actionType`.
33 */
34export interface BudgetActionDefinition {
35 /**
36 * For `APPLY_IAM_POLICY` actions — attach the managed policy to the given
37 * IAM roles, groups, and/or users.
38 */
39 iamActionDefinition?: {
40 /** ARN of the managed policy to attach. */
41 policyArn: string;
42 /** IAM role names to attach the policy to. */
43 roles?: string[];
44 /** IAM group names to attach the policy to. */
45 groups?: string[];
46 /** IAM user names to attach the policy to. */
47 users?: string[];
48 };
49 /**
50 * For `APPLY_SCP_POLICY` actions — attach the service control policy to
51 * the given Organizations targets.
52 */
53 scpActionDefinition?: {
54 /** The SCP policy ID. */
55 policyId: string;
56 /** Organizations target IDs (accounts or OUs). */
57 targetIds: string[];
58 };
59 /**
60 * For `RUN_SSM_DOCUMENTS` actions — stop the given EC2 or RDS instances.
61 */
62 ssmActionDefinition?: {
63 /** Which instances to stop. */
64 actionSubType: "STOP_EC2_INSTANCES" | "STOP_RDS_INSTANCES" | (string & {});
65 /** Region of the instances. */
66 region: string;
67 /** Instance IDs to stop. */
68 instanceIds: string[];
69 };
70}
71
72export interface BudgetActionProps {
73 /**
74 * Name of the budget the action belongs to.
75 *
76 * Changing the budget replaces the action.
77 */
78 budgetName: string;
79 /**
80 * Whether the action triggers on actual or forecasted spend.
81 */
82 notificationType: "ACTUAL" | "FORECASTED" | (string & {});
83 /**
84 * The kind of action to run when the threshold is crossed.
85 *
86 * Changing the action type replaces the action.
87 */
88 actionType:
89 | "APPLY_IAM_POLICY"
90 | "APPLY_SCP_POLICY"
91 | "RUN_SSM_DOCUMENTS"
92 | (string & {});
93 /**
94 * The threshold that triggers the action.
95 */
96 actionThreshold: BudgetActionThreshold;
97 /**
98 * What the action does — must match `actionType`.
99 */
100 definition: BudgetActionDefinition;
101 /**
102 * ARN of the IAM role AWS Budgets assumes to run the action. The role must
103 * trust `budgets.amazonaws.com`.
104 */
105 executionRoleArn: string;
106 /**
107 * Whether the action runs automatically when the threshold is crossed or
108 * requires manual approval.
109 * @default "MANUAL"
110 */
111 approvalModel?: "AUTOMATIC" | "MANUAL" | (string & {});
112 /**
113 * Subscribers notified when the action is triggered or requires approval.
114 */
115 subscribers: BudgetSubscriber[];
116 /**
117 * Tags applied to the budget action.
118 */
119 tags?: Record<string, string>;
120}
121
122export interface BudgetAction extends Resource<
123 "AWS.Budgets.BudgetAction",
124 BudgetActionProps,
125 {
126 /**
127 * The system-generated ID of the action.
128 */
129 actionId: string;
130 /**
131 * Name of the budget the action belongs to.
132 */
133 budgetName: string;
134 /**
135 * The AWS account ID that owns the action.
136 */
137 accountId: string;
138 /**
139 * ARN of the action, e.g.
140 * `arn:aws:budgets::123456789012:budget/my-budget/action/abc123`.
141 */
142 actionArn: string;
143 },
144 never,
145 Providers
146> {}
147
148/**
149 * An AWS Budgets action — runs an IAM policy attachment, SCP attachment, or
150 * SSM stop-instances document when a budget threshold is crossed, either
151 * automatically or after manual approval.
152 *
153 * The execution role must trust `budgets.amazonaws.com` and carry the
154 * permissions the action needs (the AWS managed policy
155 * `AWSBudgetsActionsWithAWSResourceControlAccess` covers all three kinds).
156 *
157 * ### Creating a Budget Action
158 * **Example:** Apply a Deny-All Policy at 100% of the Budget
159 * ```typescript
160 * import * as AWS from "alchemy/AWS";
161 *
162 * const budget = yield* AWS.Budgets.Budget("MonthlyCost", {
163 * budgetLimit: { amount: "100", unit: "USD" },
164 * });
165 *
166 * const action = yield* AWS.Budgets.BudgetAction("FreezeSpend", {
167 * budgetName: budget.budgetName,
168 * notificationType: "ACTUAL",
169 * actionType: "APPLY_IAM_POLICY",
170 * actionThreshold: {
171 * actionThresholdValue: 100,
172 * actionThresholdType: "PERCENTAGE",
173 * },
174 * definition: {
175 * iamActionDefinition: {
176 * policyArn: "arn:aws:iam::aws:policy/AWSDenyAll",
177 * roles: [devRole.roleName],
178 * },
179 * },
180 * executionRoleArn: executionRole.roleArn,
181 * approvalModel: "MANUAL",
182 * subscribers: [{ subscriptionType: "EMAIL", address: "team@example.com" }],
183 * });
184 * ```
185 *
186 * **Example:** Stop EC2 Instances Automatically
187 * ```typescript
188 * const action = yield* AWS.Budgets.BudgetAction("StopDevInstances", {
189 * budgetName: budget.budgetName,
190 * notificationType: "ACTUAL",
191 * actionType: "RUN_SSM_DOCUMENTS",
192 * actionThreshold: {
193 * actionThresholdValue: 100,
194 * actionThresholdType: "PERCENTAGE",
195 * },
196 * definition: {
197 * ssmActionDefinition: {
198 * actionSubType: "STOP_EC2_INSTANCES",
199 * region: "us-east-1",
200 * instanceIds: [instance.instanceId],
201 * },
202 * },
203 * executionRoleArn: executionRole.roleArn,
204 * approvalModel: "AUTOMATIC",
205 * subscribers: [{ subscriptionType: "EMAIL", address: "team@example.com" }],
206 * });
207 * ```
208 *
209 * @resource
210 */
211export const BudgetAction = Resource<BudgetAction>("AWS.Budgets.BudgetAction");
212
213/**
214 * Compute the ARN of a budget action. Budgets is a global service, so the ARN
215 * has no region component.
216 */
217export const budgetActionArn = (
218 accountId: string,
219 budgetName: string,
220 actionId: string,
221): string =>
222 `arn:aws:budgets::${accountId}:budget/${budgetName}/action/${actionId}`;
223
224const toDefinition = (d: BudgetActionDefinition): budgets.Definition => ({
225 IamActionDefinition: d.iamActionDefinition
226 ? {
227 PolicyArn: d.iamActionDefinition.policyArn,
228 Roles: d.iamActionDefinition.roles,
229 Groups: d.iamActionDefinition.groups,
230 Users: d.iamActionDefinition.users,
231 }
232 : undefined,
233 ScpActionDefinition: d.scpActionDefinition
234 ? {
235 PolicyId: d.scpActionDefinition.policyId,
236 TargetIds: d.scpActionDefinition.targetIds,
237 }
238 : undefined,
239 SsmActionDefinition: d.ssmActionDefinition
240 ? {
241 ActionSubType: d.ssmActionDefinition.actionSubType,
242 Region: d.ssmActionDefinition.region,
243 InstanceIds: d.ssmActionDefinition.instanceIds,
244 }
245 : undefined,
246});
247
248const toSubscribers = (subscribers: BudgetSubscriber[]): budgets.Subscriber[] =>
249 subscribers.map((s) => ({
250 SubscriptionType: s.subscriptionType,
251 Address: s.address,
252 }));
253
254/** The updatable slice of an action, in a canonical shape for comparison. */
255const updatableSlice = (a: {
256 NotificationType: string;
257 ActionThreshold: budgets.ActionThreshold;
258 Definition: budgets.Definition;
259 ExecutionRoleArn: string;
260 ApprovalModel: string;
261 Subscribers: readonly budgets.Subscriber[];
262}): string =>
263 JSON.stringify({
264 NotificationType: a.NotificationType,
265 ActionThreshold: {
266 ActionThresholdValue: a.ActionThreshold.ActionThresholdValue,
267 ActionThresholdType: a.ActionThreshold.ActionThresholdType,
268 },
269 Definition: {
270 IamActionDefinition: a.Definition.IamActionDefinition
271 ? {
272 PolicyArn: a.Definition.IamActionDefinition.PolicyArn,
273 Roles: a.Definition.IamActionDefinition.Roles ?? [],
274 Groups: a.Definition.IamActionDefinition.Groups ?? [],
275 Users: a.Definition.IamActionDefinition.Users ?? [],
276 }
277 : undefined,
278 ScpActionDefinition: a.Definition.ScpActionDefinition,
279 SsmActionDefinition: a.Definition.SsmActionDefinition,
280 },
281 ExecutionRoleArn: a.ExecutionRoleArn,
282 ApprovalModel: a.ApprovalModel,
283 // Distilled marks `Subscriber.Address` sensitive — observed subscribers
284 // come back Redacted; unwrap so the observed-vs-desired comparison does
285 // not see `<redacted>` and issue a dirty update on every reconcile.
286 Subscribers: [...a.Subscribers]
287 .map(
288 (s) =>
289 `${s.SubscriptionType}:${
290 Redacted.isRedacted(s.Address)
291 ? Redacted.value(s.Address)
292 : s.Address
293 }`,
294 )
295 .sort(),
296 });
297
298export const BudgetActionProvider = () =>
299 Provider.effect(
300 BudgetAction,
301 Effect.gen(function* () {
302 const syncTags = Effect.fn(function* (
303 arn: string,
304 desired: Record<string, string>,
305 ) {
306 const observed = yield* budgets
307 .listTagsForResource({ ResourceARN: arn })
308 .pipe(
309 Effect.map((r) =>
310 Object.fromEntries(
311 (r.ResourceTags ?? []).map((t) => [t.Key, t.Value]),
312 ),
313 ),
314 Effect.catchTag("NotFoundException", () =>
315 Effect.succeed({} as Record<string, string>),
316 ),
317 );
318 const { removed, upsert } = diffTags(observed, desired);
319 if (upsert.length > 0) {
320 yield* budgets.tagResource({
321 ResourceARN: arn,
322 ResourceTags: upsert,
323 });
324 }
325 if (removed.length > 0) {
326 yield* budgets.untagResource({
327 ResourceARN: arn,
328 ResourceTagKeys: removed,
329 });
330 }
331 });
332
333 const attrs = (accountId: string, budgetName: string, actionId: string) =>
334 ({
335 actionId,
336 budgetName,
337 accountId,
338 actionArn: budgetActionArn(accountId, budgetName, actionId),
339 }) as const;
340
341 return BudgetAction.Provider.of({
342 stables: ["actionId", "budgetName", "accountId", "actionArn"],
343 list: () =>
344 Effect.gen(function* () {
345 const { accountId } = yield* AWSEnvironment.current;
346 return yield* budgets.describeBudgetActionsForAccount
347 .pages({ AccountId: accountId })
348 .pipe(
349 Stream.runCollect,
350 Effect.map((chunk) =>
351 Array.from(chunk)
352 .flatMap((page) => page.Actions ?? [])
353 .map((a) => attrs(accountId, a.BudgetName, a.ActionId)),
354 ),
355 );
356 }),
357 read: Effect.fn(function* ({ output }) {
358 // The action ID is server-generated — without persisted output there
359 // is no deterministic way to find the action.
360 if (!output) return undefined;
361 const { accountId } = yield* AWSEnvironment.current;
362 const found = yield* budgets
363 .describeBudgetAction({
364 AccountId: accountId,
365 BudgetName: output.budgetName,
366 ActionId: output.actionId,
367 })
368 .pipe(
369 Effect.catchTag("NotFoundException", () =>
370 Effect.succeed(undefined),
371 ),
372 );
373 if (!found?.Action) return undefined;
374 return attrs(accountId, output.budgetName, output.actionId);
375 }),
376 diff: Effect.fn(function* ({ news, olds }) {
377 if (!isResolved(news)) return undefined;
378 if (
379 olds.budgetName !== news.budgetName ||
380 olds.actionType !== news.actionType
381 ) {
382 return { action: "replace" } as const;
383 }
384 }),
385 reconcile: Effect.fn(function* ({ id, news, output, session }) {
386 const { accountId } = yield* AWSEnvironment.current;
387 const internalTags = yield* createInternalTags(id);
388 const desiredSubscribers = toSubscribers(news.subscribers);
389 const desired = {
390 NotificationType: news.notificationType,
391 ActionThreshold: {
392 ActionThresholdValue: news.actionThreshold.actionThresholdValue,
393 ActionThresholdType: news.actionThreshold.actionThresholdType,
394 },
395 Definition: toDefinition(news.definition),
396 ExecutionRoleArn: news.executionRoleArn,
397 ApprovalModel: news.approvalModel ?? "MANUAL",
398 Subscribers: desiredSubscribers,
399 };
400
401 // OBSERVE — the action ID is server-generated, so `output` is the
402 // only handle; cloud state stays authoritative via describe.
403 const observed = output
404 ? yield* budgets
405 .describeBudgetAction({
406 AccountId: accountId,
407 BudgetName: output.budgetName,
408 ActionId: output.actionId,
409 })
410 .pipe(
411 Effect.map((r) => r.Action),
412 Effect.catchTag("NotFoundException", () =>
413 Effect.succeed(undefined),
414 ),
415 )
416 : undefined;
417
418 let actionId: string;
419 if (!observed) {
420 // ENSURE — create. A freshly created execution role can take a
421 // few seconds to become assumable by budgets.amazonaws.com, which
422 // surfaces as InvalidParameterException/AccessDeniedException.
423 const created = yield* budgets
424 .createBudgetAction({
425 AccountId: accountId,
426 BudgetName: news.budgetName,
427 NotificationType: desired.NotificationType,
428 ActionType: news.actionType,
429 ActionThreshold: desired.ActionThreshold,
430 Definition: desired.Definition,
431 ExecutionRoleArn: desired.ExecutionRoleArn,
432 ApprovalModel: desired.ApprovalModel,
433 Subscribers: desired.Subscribers,
434 ResourceTags: Object.entries({
435 ...news.tags,
436 ...internalTags,
437 }).map(([Key, Value]) => ({ Key, Value })),
438 })
439 .pipe(
440 Effect.retry({
441 while: (e): boolean =>
442 e._tag === "InvalidParameterException" ||
443 e._tag === "AccessDeniedException",
444 schedule: Schedule.exponential("2 seconds"),
445 times: 6,
446 }),
447 );
448 actionId = created.ActionId;
449 } else {
450 actionId = observed.ActionId;
451 // SYNC — apply the update only when the observed action differs
452 // from the desired definition.
453 if (updatableSlice(observed) !== updatableSlice(desired)) {
454 yield* budgets
455 .updateBudgetAction({
456 AccountId: accountId,
457 BudgetName: news.budgetName,
458 ActionId: actionId,
459 NotificationType: desired.NotificationType,
460 ActionThreshold: desired.ActionThreshold,
461 Definition: desired.Definition,
462 ExecutionRoleArn: desired.ExecutionRoleArn,
463 ApprovalModel: desired.ApprovalModel,
464 Subscribers: desired.Subscribers,
465 })
466 .pipe(
467 Effect.retry({
468 while: (e): boolean => e._tag === "ResourceLockedException",
469 schedule: Schedule.exponential("2 seconds"),
470 times: 6,
471 }),
472 );
473 }
474 }
475
476 // SYNC tags against observed cloud tags.
477 const arn = budgetActionArn(accountId, news.budgetName, actionId);
478 yield* syncTags(arn, { ...news.tags, ...internalTags });
479
480 yield* session.note(actionId);
481 return attrs(accountId, news.budgetName, actionId);
482 }),
483 delete: Effect.fn(function* ({ output }) {
484 yield* budgets
485 .deleteBudgetAction({
486 AccountId: output.accountId,
487 BudgetName: output.budgetName,
488 ActionId: output.actionId,
489 })
490 .pipe(
491 // An action mid-execution is briefly locked; wait it out.
492 Effect.retry({
493 while: (e): boolean => e._tag === "ResourceLockedException",
494 schedule: Schedule.exponential("2 seconds"),
495 times: 6,
496 }),
497 Effect.catchTag("NotFoundException", () => Effect.void),
498 );
499 }),
500 });
501 }),
502 );