1import * as budgets from "@distilled.cloud/aws/budgets";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import * as Stream from "effect/Stream";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.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";
12
13/**
14 * A subscriber notified when a budget notification is triggered.
15 */
16export interface BudgetSubscriber {
17 /**
18 * How the subscriber is notified.
19 */
20 subscriptionType: "EMAIL" | "SNS" | (string & {});
21 /**
22 * The destination — an email address (for `EMAIL`) or an SNS topic ARN
23 * (for `SNS`).
24 */
25 address: string;
26}
27
28/**
29 * A threshold that, when crossed, notifies the configured subscribers.
30 */
31export interface BudgetNotification {
32 /**
33 * Whether the notification is based on actual or forecasted spend/usage.
34 */
35 notificationType: "ACTUAL" | "FORECASTED" | (string & {});
36 /**
37 * How the actual/forecasted amount is compared to the threshold.
38 */
39 comparisonOperator: "GREATER_THAN" | "LESS_THAN" | "EQUAL_TO" | (string & {});
40 /**
41 * The threshold value. Interpreted as a percentage of the budget limit by
42 * default, or an absolute amount when `thresholdType` is `ABSOLUTE_VALUE`.
43 */
44 threshold: number;
45 /**
46 * Whether `threshold` is a percentage of the budget or an absolute value.
47 * @default "PERCENTAGE"
48 */
49 thresholdType?: "PERCENTAGE" | "ABSOLUTE_VALUE" | (string & {});
50 /**
51 * Subscribers notified when this threshold is crossed.
52 */
53 subscribers: BudgetSubscriber[];
54}
55
56export interface BudgetProps {
57 /**
58 * Name of the budget. Must be unique within the account. If omitted, a
59 * unique name is generated from the app, stage, and logical ID.
60 *
61 * Changing the name replaces the budget.
62 */
63 budgetName?: string;
64 /**
65 * What the budget tracks.
66 * @default "COST"
67 */
68 budgetType?:
69 | "COST"
70 | "USAGE"
71 | "RI_UTILIZATION"
72 | "RI_COVERAGE"
73 | "SAVINGS_PLANS_UTILIZATION"
74 | "SAVINGS_PLANS_COVERAGE"
75 | (string & {});
76 /**
77 * The period the budget resets over.
78 * @default "MONTHLY"
79 */
80 timeUnit?: "DAILY" | "MONTHLY" | "QUARTERLY" | "ANNUALLY" | (string & {});
81 /**
82 * The budgeted amount and its unit, e.g. `{ amount: "100", unit: "USD" }`.
83 * Required for `COST` and `USAGE` budgets.
84 */
85 budgetLimit?: {
86 /** The amount, as a string, e.g. `"100"`. */
87 amount: string;
88 /** The unit — `"USD"` for cost budgets, or the usage unit. */
89 unit: string;
90 };
91 /**
92 * Cost filters restricting which costs count against the budget, e.g.
93 * `{ Service: ["Amazon Elastic Compute Cloud - Compute"] }`.
94 */
95 costFilters?: Record<string, string[]>;
96 /**
97 * Notifications and their subscribers.
98 */
99 notifications?: BudgetNotification[];
100 /**
101 * Tags applied to the budget at creation.
102 */
103 tags?: Record<string, string>;
104}
105
106export interface Budget extends Resource<
107 "AWS.Budgets.Budget",
108 BudgetProps,
109 {
110 /**
111 * Name of the budget.
112 */
113 budgetName: string;
114 /**
115 * The AWS account ID that owns the budget.
116 */
117 accountId: string;
118 /**
119 * ARN of the budget, e.g. `arn:aws:budgets::123456789012:budget/my-budget`.
120 */
121 budgetArn: string;
122 },
123 never,
124 Providers
125> {}
126
127/**
128 * An AWS Budget — tracks cost or usage against a defined limit over a time
129 * period and notifies subscribers when configured thresholds are crossed.
130 *
131 * Budgets are a global (account-level) resource; they are free and take
132 * effect immediately.
133 *
134 * ### Creating a Budget
135 * **Example:** Monthly cost budget with an email alert at 80%
136 * ```typescript
137 * import * as Budgets from "alchemy/AWS/Budgets";
138 *
139 * const budget = yield* Budgets.Budget("MonthlyCost", {
140 * budgetType: "COST",
141 * timeUnit: "MONTHLY",
142 * budgetLimit: { amount: "100", unit: "USD" },
143 * notifications: [
144 * {
145 * notificationType: "ACTUAL",
146 * comparisonOperator: "GREATER_THAN",
147 * threshold: 80,
148 * thresholdType: "PERCENTAGE",
149 * subscribers: [{ subscriptionType: "EMAIL", address: "team@example.com" }],
150 * },
151 * ],
152 * });
153 * ```
154 *
155 * **Example:** Budget scoped to a single service
156 * ```typescript
157 * const budget = yield* Budgets.Budget("EC2Spend", {
158 * budgetLimit: { amount: "500", unit: "USD" },
159 * costFilters: {
160 * Service: ["Amazon Elastic Compute Cloud - Compute"],
161 * },
162 * });
163 * ```
164 *
165 * @resource
166 */
167export const Budget = Resource<Budget>("AWS.Budgets.Budget");
168
169/**
170 * Compute the ARN of a budget. Budgets is a global service, so the ARN has no
171 * region component.
172 */
173export const budgetArn = (accountId: string, budgetName: string): string =>
174 `arn:aws:budgets::${accountId}:budget/${budgetName}`;
175
176const notificationKey = (n: budgets.Notification): string =>
177 JSON.stringify({
178 NotificationType: n.NotificationType,
179 ComparisonOperator: n.ComparisonOperator,
180 Threshold: n.Threshold,
181 ThresholdType: n.ThresholdType ?? "PERCENTAGE",
182 });
183
184const toNotification = (n: BudgetNotification): budgets.Notification => ({
185 NotificationType: n.notificationType,
186 ComparisonOperator: n.comparisonOperator,
187 Threshold: n.threshold,
188 ThresholdType: n.thresholdType ?? "PERCENTAGE",
189});
190
191/**
192 * Distilled marks `Subscriber.Address` sensitive, so observed subscribers
193 * come back as `Redacted` — unwrap before diffing, or every observed
194 * subscriber compares as `<redacted>` and gets torn down (and deleting the
195 * last subscriber deletes the whole notification).
196 */
197const subscriberAddress = (s: budgets.Subscriber): string =>
198 Redacted.isRedacted(s.Address) ? Redacted.value(s.Address) : s.Address;
199
200const subscriberKey = (s: budgets.Subscriber): string =>
201 `${s.SubscriptionType}:${subscriberAddress(s)}`;
202
203export const BudgetProvider = () =>
204 Provider.effect(
205 Budget,
206 Effect.gen(function* () {
207 const createName = Effect.fn(function* (
208 id: string,
209 props: { budgetName?: string | undefined },
210 ) {
211 return (
212 props.budgetName ??
213 (yield* createPhysicalName({ id, maxLength: 100 }))
214 );
215 });
216
217 const buildBudget = (
218 name: string,
219 props: BudgetProps,
220 ): budgets.Budget => ({
221 BudgetName: name,
222 BudgetType: props.budgetType ?? "COST",
223 TimeUnit: props.timeUnit ?? "MONTHLY",
224 BudgetLimit: props.budgetLimit
225 ? { Amount: props.budgetLimit.amount, Unit: props.budgetLimit.unit }
226 : undefined,
227 CostFilters: props.costFilters,
228 });
229
230 const syncNotifications = Effect.fn(function* (
231 accountId: string,
232 name: string,
233 desired: BudgetNotification[],
234 ) {
235 const current = yield* budgets.describeNotificationsForBudget
236 .pages({
237 AccountId: accountId,
238 BudgetName: name,
239 })
240 .pipe(
241 Stream.runCollect,
242 Effect.map((chunk) =>
243 Array.from(chunk).flatMap((page) => page.Notifications ?? []),
244 ),
245 Effect.catchTag("NotFoundException", () => Effect.succeed([])),
246 );
247 const currentKeys = new Set(current.map(notificationKey));
248 const desiredNotifs = desired.map(toNotification);
249 const desiredKeys = new Set(desiredNotifs.map(notificationKey));
250
251 for (const n of desired) {
252 const notif = toNotification(n);
253 const desiredSubscribers = n.subscribers.map(
254 (s): budgets.Subscriber => ({
255 SubscriptionType: s.subscriptionType,
256 Address: s.address,
257 }),
258 );
259 if (!currentKeys.has(notificationKey(notif))) {
260 // The notification listing is eventually consistent — right after
261 // `createBudget(NotificationsWithSubscribers)` it can come back
262 // empty, so an already-created notification surfaces here as a
263 // DuplicateRecordException race.
264 yield* budgets
265 .createNotification({
266 AccountId: accountId,
267 BudgetName: name,
268 Notification: notif,
269 Subscribers: desiredSubscribers,
270 })
271 .pipe(
272 Effect.catchTag("DuplicateRecordException", () => Effect.void),
273 );
274 continue;
275 }
276 // The notification already exists — converge its subscribers by
277 // diffing the OBSERVED subscriber list against the desired one.
278 const observedSubscribers = yield* budgets
279 .describeSubscribersForNotification({
280 AccountId: accountId,
281 BudgetName: name,
282 Notification: notif,
283 })
284 .pipe(
285 Effect.map((r) => r.Subscribers ?? []),
286 Effect.catchTag("NotFoundException", () => Effect.succeed([])),
287 );
288 const observedKeys = new Set(observedSubscribers.map(subscriberKey));
289 const desiredSubKeys = new Set(desiredSubscribers.map(subscriberKey));
290 // Create before delete so the notification never drops to zero
291 // subscribers (the API requires at least one).
292 for (const s of desiredSubscribers) {
293 if (observedKeys.has(subscriberKey(s))) continue;
294 yield* budgets
295 .createSubscriber({
296 AccountId: accountId,
297 BudgetName: name,
298 Notification: notif,
299 Subscriber: s,
300 })
301 .pipe(
302 Effect.catchTag("DuplicateRecordException", () => Effect.void),
303 );
304 }
305 for (const s of observedSubscribers) {
306 if (desiredSubKeys.has(subscriberKey(s))) continue;
307 yield* budgets
308 .deleteSubscriber({
309 AccountId: accountId,
310 BudgetName: name,
311 Notification: notif,
312 Subscriber: s,
313 })
314 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
315 }
316 }
317 for (const n of current) {
318 if (desiredKeys.has(notificationKey(n))) continue;
319 yield* budgets
320 .deleteNotification({
321 AccountId: accountId,
322 BudgetName: name,
323 Notification: n,
324 })
325 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
326 }
327 });
328
329 const syncTags = Effect.fn(function* (
330 arn: string,
331 desired: Record<string, string>,
332 ) {
333 // Diff against OBSERVED cloud tags (not olds/output) so adoption and
334 // out-of-band drift converge correctly.
335 const observed = yield* budgets
336 .listTagsForResource({ ResourceARN: arn })
337 .pipe(
338 Effect.map((r) =>
339 Object.fromEntries(
340 (r.ResourceTags ?? []).map((t) => [t.Key, t.Value]),
341 ),
342 ),
343 Effect.catchTag("NotFoundException", () =>
344 Effect.succeed({} as Record<string, string>),
345 ),
346 );
347 const { removed, upsert } = diffTags(observed, desired);
348 if (upsert.length > 0) {
349 yield* budgets.tagResource({
350 ResourceARN: arn,
351 ResourceTags: upsert,
352 });
353 }
354 if (removed.length > 0) {
355 yield* budgets.untagResource({
356 ResourceARN: arn,
357 ResourceTagKeys: removed,
358 });
359 }
360 });
361
362 return Budget.Provider.of({
363 stables: ["budgetName", "accountId", "budgetArn"],
364 list: () =>
365 Effect.gen(function* () {
366 const { accountId } = yield* AWSEnvironment.current;
367 return yield* budgets.describeBudgets
368 .pages({ AccountId: accountId })
369 .pipe(
370 Stream.runCollect,
371 Effect.map((chunk) =>
372 Array.from(chunk)
373 .flatMap((page) => page.Budgets ?? [])
374 .map((b) => ({
375 budgetName: b.BudgetName,
376 accountId,
377 budgetArn: budgetArn(accountId, b.BudgetName),
378 })),
379 ),
380 Effect.catchTag("NotFoundException", () => Effect.succeed([])),
381 );
382 }),
383 read: Effect.fn(function* ({ id, olds, output }) {
384 const { accountId } = yield* AWSEnvironment.current;
385 const name =
386 output?.budgetName ?? (yield* createName(id, olds ?? {}));
387 const found = yield* budgets
388 .describeBudget({ AccountId: accountId, BudgetName: name })
389 .pipe(
390 Effect.catchTag("NotFoundException", () =>
391 Effect.succeed(undefined),
392 ),
393 );
394 if (!found?.Budget) return undefined;
395 return {
396 budgetName: name,
397 accountId,
398 budgetArn: budgetArn(accountId, name),
399 };
400 }),
401 diff: Effect.fn(function* ({ id, news = {}, olds = {} }) {
402 if (!isResolved(news)) return undefined;
403 const oldName = yield* createName(id, olds);
404 const newName = yield* createName(id, news);
405 if (oldName !== newName) return { action: "replace" } as const;
406 }),
407 reconcile: Effect.fn(function* ({ id, news = {}, output, session }) {
408 const { accountId } = yield* AWSEnvironment.current;
409 const name = output?.budgetName ?? (yield* createName(id, news));
410 const internalTags = yield* createInternalTags(id);
411 const budgetBody = buildBudget(name, news);
412
413 // OBSERVE — cloud state is authoritative.
414 const live = yield* budgets
415 .describeBudget({ AccountId: accountId, BudgetName: name })
416 .pipe(
417 Effect.map((r) => r.Budget),
418 Effect.catchTag("NotFoundException", () =>
419 Effect.succeed(undefined),
420 ),
421 );
422
423 if (!live) {
424 // ENSURE — create; tolerate a DuplicateRecord race.
425 yield* budgets
426 .createBudget({
427 AccountId: accountId,
428 Budget: budgetBody,
429 NotificationsWithSubscribers: news.notifications?.map((n) => ({
430 Notification: toNotification(n),
431 Subscribers: n.subscribers.map((s) => ({
432 SubscriptionType: s.subscriptionType,
433 Address: s.address,
434 })),
435 })),
436 ResourceTags: Object.entries({
437 ...news.tags,
438 ...internalTags,
439 }).map(([Key, Value]) => ({ Key, Value })),
440 })
441 .pipe(
442 Effect.catchTag("DuplicateRecordException", () =>
443 budgets.updateBudget({
444 AccountId: accountId,
445 NewBudget: budgetBody,
446 }),
447 ),
448 );
449 } else {
450 // SYNC — updateBudget is a full replace of the budget definition.
451 yield* budgets.updateBudget({
452 AccountId: accountId,
453 NewBudget: budgetBody,
454 });
455 }
456
457 // SYNC notifications — the budget update does not touch these.
458 yield* syncNotifications(accountId, name, news.notifications ?? []);
459
460 // SYNC tags against observed cloud tags.
461 const arn = budgetArn(accountId, name);
462 yield* syncTags(arn, { ...news.tags, ...internalTags });
463
464 yield* session.note(name);
465 return { budgetName: name, accountId, budgetArn: arn };
466 }),
467 delete: Effect.fn(function* ({ output }) {
468 yield* budgets
469 .deleteBudget({
470 AccountId: output.accountId,
471 BudgetName: output.budgetName,
472 })
473 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
474 }),
475 });
476 }),
477 );