1import * as cur from "@distilled.cloud/aws/cost-and-usage-report-service";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
11import { AWSEnvironment } from "../Environment.ts";
12import type { Providers } from "../Providers.ts";
13import { Region } from "../Region.ts";
14
15// The Cost and Usage Report API (`cur`) is a global service hosted only in
16// us-east-1 (the report's delivery bucket may live in any region — that is
17// what `s3Region` describes). Every control-plane call is pinned there
18// regardless of the ambient stack region. The distilled Region service value
19// is `Effect<RegionName>`, not a raw string, hence Effect.succeed.
20const CUR_REGION = "us-east-1";
21const pin = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
22 effect.pipe(Effect.provideService(Region, Effect.succeed(CUR_REGION)));
23
24// PutReportDefinition validates that the delivery bucket's policy grants
25// billingreports.amazonaws.com access. A policy applied moments earlier (the
26// usual case — the bucket is deployed in the same stack) can still be
27// propagating, surfacing as a bucket-mentioning ValidationException (patched
28// in distilled into the typed ReportBucketNotVerified tag). Bounded retry:
29// 8 recurrences x 3s ≈ 24s. Explicit return annotation so the Retry.Return
30// conditional type never leaks into declaration emit.
31const retryBucketVerification = <A, E extends { readonly _tag: string }, R>(
32 self: Effect.Effect<A, E, R>,
33): Effect.Effect<A, E, R> =>
34 Effect.retry(self, {
35 while: (e) => e._tag === "ReportBucketNotVerified",
36 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(8)]),
37 });
38
39export interface ReportDefinitionProps {
40 /**
41 * Name of the report (also the prefix of the report files in S3). Must be
42 * unique in the account; up to 256 characters of `A-Za-z0-9!\-_.*'()`.
43 * If omitted, a unique name is generated from the app, stage, and logical
44 * ID.
45 *
46 * Changing the name replaces the report definition.
47 */
48 reportName?: string;
49 /**
50 * The granularity of line items in the report.
51 */
52 timeUnit: "HOURLY" | "DAILY" | "MONTHLY" | (string & {});
53 /**
54 * The format the report files are generated in.
55 */
56 format: "textORcsv" | "Parquet" | (string & {});
57 /**
58 * The compression applied to the report files. Must be `Parquet` when
59 * `format` is `Parquet`.
60 */
61 compression: "ZIP" | "GZIP" | "Parquet" | (string & {});
62 /**
63 * Additional detail included in the report, e.g. `["RESOURCES"]` to include
64 * individual resource IDs.
65 * @default []
66 */
67 additionalSchemaElements?: (
68 | "RESOURCES"
69 | "SPLIT_COST_ALLOCATION_DATA"
70 | "MANUAL_DISCOUNT_COMPATIBILITY"
71 | (string & {})
72 )[];
73 /**
74 * Name of the S3 bucket the report is delivered to. The bucket policy must
75 * allow `billingreports.amazonaws.com` to call `s3:GetBucketAcl`,
76 * `s3:GetBucketPolicy`, and `s3:PutObject`.
77 */
78 s3Bucket: string;
79 /**
80 * S3 key prefix the report files are delivered under.
81 */
82 s3Prefix: string;
83 /**
84 * The region the delivery bucket lives in, e.g. `us-east-1`.
85 */
86 s3Region: string;
87 /**
88 * Artifacts AWS additionally prepares the report for (`REDSHIFT`,
89 * `QUICKSIGHT`, or `ATHENA`). `ATHENA` requires Parquet format/compression
90 * and `OVERWRITE_REPORT` versioning, and cannot be combined with the other
91 * artifacts.
92 */
93 additionalArtifacts?: (
94 | "REDSHIFT"
95 | "QUICKSIGHT"
96 | "ATHENA"
97 | (string & {})
98 )[];
99 /**
100 * Whether AWS updates previously delivered reports when charges are applied
101 * retroactively (refunds, credits, RI fees).
102 * @default true
103 */
104 refreshClosedReports?: boolean;
105 /**
106 * Whether each report update overwrites the previous version
107 * (`OVERWRITE_REPORT`) or is delivered alongside it (`CREATE_NEW_REPORT`).
108 * @default "CREATE_NEW_REPORT"
109 */
110 reportVersioning?: "CREATE_NEW_REPORT" | "OVERWRITE_REPORT" | (string & {});
111 /**
112 * ARN of the billing view the report is scoped to. Omit for the account's
113 * primary billing view.
114 *
115 * Changing the billing view replaces the report definition.
116 */
117 billingViewArn?: string;
118 /**
119 * Tags to apply to the report definition.
120 */
121 tags?: Record<string, string>;
122}
123
124export interface ReportDefinition extends Resource<
125 "AWS.CostAndUsageReport.ReportDefinition",
126 ReportDefinitionProps,
127 {
128 /** Name of the report definition. */
129 reportName: string;
130 /** ARN of the report definition. */
131 reportArn: string;
132 /** Granularity of the report (`HOURLY`, `DAILY`, `MONTHLY`). */
133 timeUnit: string;
134 /** File format of the report (`textORcsv` or `Parquet`). */
135 format: string;
136 /** Compression applied to report files (`ZIP`, `GZIP`, `Parquet`). */
137 compression: string;
138 /** Name of the S3 bucket the report is delivered to. */
139 s3Bucket: string;
140 /** S3 key prefix the report is delivered under. */
141 s3Prefix: string;
142 /** Region of the delivery S3 bucket. */
143 s3Region: string;
144 },
145 never,
146 Providers
147> {}
148
149/**
150 * An AWS Cost and Usage Report (CUR) definition — the most granular billing
151 * data AWS offers, delivered as CSV or Parquet files to an S3 bucket you own.
152 *
153 * The CUR API is a global service hosted only in `us-east-1`; this resource
154 * pins every control-plane call there regardless of the stack region. The
155 * delivery bucket may live in any region (declared via `s3Region`) but its
156 * bucket policy must grant `billingreports.amazonaws.com` the
157 * `s3:GetBucketAcl`, `s3:GetBucketPolicy`, and `s3:PutObject` permissions —
158 * report creation fails validation otherwise.
159 *
160 * Report definitions are free; you pay only for the S3 storage of delivered
161 * reports.
162 *
163 * ### Creating a Report
164 * **Example:** Daily CSV report with resource IDs
165 * ```typescript
166 * import * as AWS from "alchemy/AWS";
167 *
168 * const bucket = yield* AWS.S3.Bucket("ReportBucket", {
169 * bucketName: "my-cur-reports",
170 * policy: [
171 * {
172 * Effect: "Allow",
173 * Principal: { Service: "billingreports.amazonaws.com" },
174 * Action: ["s3:GetBucketAcl", "s3:GetBucketPolicy"],
175 * Resource: "arn:aws:s3:::my-cur-reports",
176 * },
177 * {
178 * Effect: "Allow",
179 * Principal: { Service: "billingreports.amazonaws.com" },
180 * Action: ["s3:PutObject"],
181 * Resource: "arn:aws:s3:::my-cur-reports/*",
182 * },
183 * ],
184 * });
185 *
186 * const report = yield* AWS.CostAndUsageReport.ReportDefinition("Costs", {
187 * timeUnit: "DAILY",
188 * format: "textORcsv",
189 * compression: "GZIP",
190 * additionalSchemaElements: ["RESOURCES"],
191 * s3Bucket: bucket.bucketName,
192 * s3Prefix: "cur",
193 * s3Region: bucket.region,
194 * });
195 * ```
196 *
197 * **Example:** Athena-ready Parquet report
198 * ```typescript
199 * const report = yield* AWS.CostAndUsageReport.ReportDefinition("Athena", {
200 * timeUnit: "HOURLY",
201 * format: "Parquet",
202 * compression: "Parquet",
203 * additionalArtifacts: ["ATHENA"],
204 * reportVersioning: "OVERWRITE_REPORT",
205 * s3Bucket: bucket.bucketName,
206 * s3Prefix: "athena-cur",
207 * s3Region: bucket.region,
208 * });
209 * ```
210 *
211 * @resource
212 */
213export const ReportDefinition = Resource<ReportDefinition>(
214 "AWS.CostAndUsageReport.ReportDefinition",
215);
216
217const toTagRecord = (tags: cur.Tag[] | undefined): Record<string, string> =>
218 Object.fromEntries((tags ?? []).map((t) => [t.Key, t.Value]));
219
220const setEquals = (
221 left: readonly string[] | undefined,
222 right: readonly string[] | undefined,
223): boolean => {
224 const l = [...(left ?? [])].sort();
225 const r = [...(right ?? [])].sort();
226 return l.length === r.length && l.every((v, i) => v === r[i]);
227};
228
229export const ReportDefinitionProvider = () =>
230 Provider.effect(
231 ReportDefinition,
232 Effect.gen(function* () {
233 const createName = Effect.fn(function* (
234 id: string,
235 props: { reportName?: string | undefined },
236 ) {
237 return (
238 props.reportName ??
239 (yield* createPhysicalName({ id, maxLength: 256 }))
240 );
241 });
242
243 const reportArn = (accountId: string, name: string) =>
244 `arn:aws:cur:${CUR_REGION}:${accountId}:definition/${name}`;
245
246 // There is no Get operation — observe by scanning the (small,
247 // account-max ~10 entries) paginated list.
248 const findReport = Effect.fn(function* (name: string) {
249 const pages = yield* pin(
250 cur.describeReportDefinitions.pages({}).pipe(Stream.runCollect),
251 );
252 return Array.from(pages)
253 .flatMap((page) => page.ReportDefinitions ?? [])
254 .find((r) => r.ReportName === name);
255 });
256
257 const readTags = (name: string) =>
258 pin(cur.listTagsForResource({ ReportName: name })).pipe(
259 Effect.map((r) => toTagRecord(r.Tags)),
260 Effect.catchTag("ResourceNotFoundException", () =>
261 Effect.succeed({} as Record<string, string>),
262 ),
263 );
264
265 const buildDefinition = (
266 name: string,
267 props: ReportDefinitionProps,
268 ): cur.ReportDefinition => ({
269 ReportName: name,
270 TimeUnit: props.timeUnit,
271 Format: props.format,
272 Compression: props.compression,
273 AdditionalSchemaElements: props.additionalSchemaElements ?? [],
274 S3Bucket: props.s3Bucket,
275 S3Prefix: props.s3Prefix,
276 S3Region: props.s3Region,
277 AdditionalArtifacts: props.additionalArtifacts,
278 RefreshClosedReports: props.refreshClosedReports,
279 ReportVersioning: props.reportVersioning,
280 BillingViewArn: props.billingViewArn,
281 });
282
283 const needsModify = (
284 observed: cur.ReportDefinition,
285 desired: cur.ReportDefinition,
286 ): boolean =>
287 observed.TimeUnit !== desired.TimeUnit ||
288 observed.Format !== desired.Format ||
289 observed.Compression !== desired.Compression ||
290 observed.S3Bucket !== desired.S3Bucket ||
291 observed.S3Prefix !== desired.S3Prefix ||
292 observed.S3Region !== desired.S3Region ||
293 !setEquals(
294 observed.AdditionalSchemaElements,
295 desired.AdditionalSchemaElements,
296 ) ||
297 !setEquals(observed.AdditionalArtifacts, desired.AdditionalArtifacts) ||
298 (desired.RefreshClosedReports !== undefined &&
299 observed.RefreshClosedReports !== desired.RefreshClosedReports) ||
300 (desired.ReportVersioning !== undefined &&
301 observed.ReportVersioning !== desired.ReportVersioning);
302
303 const toAttributes = (
304 accountId: string,
305 observed: cur.ReportDefinition,
306 ) => ({
307 reportName: observed.ReportName,
308 reportArn: reportArn(accountId, observed.ReportName),
309 timeUnit: observed.TimeUnit,
310 format: observed.Format,
311 compression: observed.Compression,
312 s3Bucket: observed.S3Bucket,
313 s3Prefix: observed.S3Prefix,
314 s3Region: observed.S3Region,
315 });
316
317 const syncTags = Effect.fn(function* (
318 name: string,
319 desiredTags: Record<string, string>,
320 ) {
321 // Diff against OBSERVED cloud tags — adoption may bring foreign tags.
322 const observedTags = yield* readTags(name);
323 const { upsert, removed } = diffTags(observedTags, desiredTags);
324 if (upsert.length > 0) {
325 yield* pin(cur.tagResource({ ReportName: name, Tags: upsert }));
326 }
327 if (removed.length > 0) {
328 yield* pin(cur.untagResource({ ReportName: name, TagKeys: removed }));
329 }
330 });
331
332 return ReportDefinition.Provider.of({
333 stables: ["reportName", "reportArn"],
334 list: () =>
335 Effect.gen(function* () {
336 const { accountId } = yield* AWSEnvironment.current;
337 const pages = yield* pin(
338 cur.describeReportDefinitions.pages({}).pipe(Stream.runCollect),
339 );
340 return Array.from(pages)
341 .flatMap((page) => page.ReportDefinitions ?? [])
342 .map((r) => toAttributes(accountId, r));
343 }),
344 read: Effect.fn(function* ({ id, olds, output }) {
345 const { accountId } = yield* AWSEnvironment.current;
346 const name =
347 output?.reportName ?? (yield* createName(id, olds ?? {}));
348 const observed = yield* findReport(name);
349 if (!observed) return undefined;
350 const attrs = toAttributes(accountId, observed);
351 const tags = yield* readTags(name);
352 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
353 }),
354 diff: Effect.fn(function* ({ id, olds, news }) {
355 if (!isResolved(news)) return;
356 const oldName = yield* createName(id, olds ?? {});
357 const newName = yield* createName(id, news);
358 if (oldName !== newName) return { action: "replace" } as const;
359 if (olds && olds.billingViewArn !== news.billingViewArn) {
360 return { action: "replace" } as const;
361 }
362 }),
363 reconcile: Effect.fn(function* ({ id, news, output, session }) {
364 const { accountId } = yield* AWSEnvironment.current;
365 const name = output?.reportName ?? (yield* createName(id, news));
366 const internalTags = yield* createInternalTags(id);
367 const desiredTags = { ...news.tags, ...internalTags };
368 const desired = buildDefinition(name, news);
369
370 // OBSERVE — cloud state is authoritative; output is only a name cache.
371 const observed = yield* findReport(name);
372
373 if (!observed) {
374 // ENSURE — create; tolerate the duplicate-name race by converging
375 // through modify instead.
376 yield* retryBucketVerification(
377 pin(
378 cur.putReportDefinition({
379 ReportDefinition: desired,
380 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
381 Key,
382 Value,
383 })),
384 }),
385 ),
386 ).pipe(
387 Effect.catchTag("DuplicateReportNameException", () =>
388 pin(
389 cur.modifyReportDefinition({
390 ReportName: name,
391 ReportDefinition: desired,
392 }),
393 ),
394 ),
395 );
396 } else if (needsModify(observed, desired)) {
397 // SYNC — apply the delta only; skip the API entirely on no-op.
398 yield* retryBucketVerification(
399 pin(
400 cur.modifyReportDefinition({
401 ReportName: name,
402 ReportDefinition: desired,
403 }),
404 ),
405 );
406 }
407
408 // SYNC TAGS — against observed cloud tags.
409 yield* syncTags(name, desiredTags);
410
411 yield* session.note(name);
412 const final = yield* findReport(name);
413 return toAttributes(accountId, final ?? desired);
414 }),
415 delete: Effect.fn(function* ({ output }) {
416 // Idempotent: DeleteReportDefinition's error semantics for a missing
417 // report are undocumented, so observe first and treat absent as done.
418 const observed = yield* findReport(output.reportName);
419 if (!observed) return;
420 yield* pin(
421 cur.deleteReportDefinition({ ReportName: output.reportName }),
422 );
423 }),
424 });
425 }),
426 );