1import * as s3control from "@distilled.cloud/aws/s3-control";
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 {
11 createTagsList,
12 createInternalTags,
13 diffTags,
14 hasAlchemyTags,
15} from "../../Tags.ts";
16import { AWSEnvironment } from "../Environment.ts";
17import type { Providers } from "../Providers.ts";
18
19export interface StorageLensConfigurationProps {
20 /**
21 * ID of the Storage Lens configuration (1-64 characters: letters,
22 * numbers, `-`, `_` and `.`). If omitted, a unique ID is generated
23 * from the app, stage and logical ID.
24 *
25 * Changing the ID replaces the configuration.
26 * @default ${app}-${stage}-${id}
27 */
28 configId?: string;
29 /**
30 * Whether the S3 Storage Lens configuration is enabled (actively
31 * aggregating metrics).
32 * @default true
33 */
34 isEnabled?: boolean;
35 /**
36 * Account-level metrics configuration (activity metrics, advanced
37 * metrics, prefix-level metrics, ...). Passed through to the S3 Control
38 * API verbatim.
39 * @default { BucketLevel: {} } — free metrics for every bucket
40 */
41 accountLevel?: s3control.AccountLevel;
42 /**
43 * Restrict the dashboard to specific buckets and/or regions. Mutually
44 * exclusive with `exclude`.
45 */
46 include?: s3control.Include;
47 /**
48 * Exclude specific buckets and/or regions from the dashboard. Mutually
49 * exclusive with `include`.
50 */
51 exclude?: s3control.Exclude;
52 /**
53 * Export the daily metrics to an S3 bucket and/or publish them to
54 * CloudWatch.
55 */
56 dataExport?: s3control.StorageLensDataExport;
57 /**
58 * ARN of the AWS Organization to aggregate metrics across member
59 * accounts (requires trusted access / delegated administration).
60 */
61 awsOrg?: string;
62 /**
63 * Tags to apply to the configuration. Merged with internal Alchemy tags.
64 */
65 tags?: Record<string, string>;
66}
67
68export interface StorageLensConfiguration extends Resource<
69 "AWS.S3Control.StorageLensConfiguration",
70 StorageLensConfigurationProps,
71 {
72 /**
73 * ID of the Storage Lens configuration.
74 */
75 configId: string;
76 /**
77 * ARN of the Storage Lens configuration.
78 */
79 storageLensArn: string;
80 },
81 never,
82 Providers
83> {}
84
85/**
86 * An Amazon S3 Storage Lens configuration — an account-wide (or
87 * organization-wide) storage analytics dashboard aggregating usage and
88 * activity metrics across buckets, with optional daily export to S3 or
89 * CloudWatch.
90 * ### Creating Dashboards
91 * **Example:** Free-metrics dashboard over the whole account
92 * ```typescript
93 * import * as S3Control from "alchemy/AWS/S3Control";
94 *
95 * const lens = yield* S3Control.StorageLensConfiguration("account-lens", {});
96 * ```
97 *
98 * **Example:** Dashboard scoped to specific buckets
99 * ```typescript
100 * const lens = yield* S3Control.StorageLensConfiguration("data-lens", {
101 * include: {
102 * Buckets: [bucket.bucketArn],
103 * },
104 * });
105 * ```
106 *
107 * **Example:** Advanced metrics with S3 export
108 * ```typescript
109 * const lens = yield* S3Control.StorageLensConfiguration("advanced-lens", {
110 * accountLevel: {
111 * ActivityMetrics: { IsEnabled: true },
112 * BucketLevel: {
113 * ActivityMetrics: { IsEnabled: true },
114 * },
115 * },
116 * dataExport: {
117 * S3BucketDestination: {
118 * Format: "CSV",
119 * OutputSchemaVersion: "V_1",
120 * AccountId: accountId,
121 * Arn: reportBucket.bucketArn,
122 * },
123 * },
124 * });
125 * ```
126 *
127 * **Example:** Disable a dashboard without deleting it
128 * ```typescript
129 * const lens = yield* S3Control.StorageLensConfiguration("account-lens", {
130 * isEnabled: false,
131 * });
132 * ```
133 *
134 * @resource
135 */
136export const StorageLensConfiguration = Resource<StorageLensConfiguration>(
137 "AWS.S3Control.StorageLensConfiguration",
138);
139
140/**
141 * Retry while a freshly-written Storage Lens configuration has not
142 * propagated to reads yet.
143 *
144 * Explicitly typed at module scope — inlining `Effect.retry` in a lifecycle
145 * op leaks `Retry.Return`'s conditional type into declaration emit, widening
146 * the provider layer to `unknown` and poisoning `AWS.providers()`.
147 */
148const retryWhileConfigurationPropagates = <A, E extends { _tag: string }, R>(
149 self: Effect.Effect<A, E, R>,
150): Effect.Effect<A, E, R> =>
151 Effect.retry(self, {
152 while: (e) => e._tag === "NoSuchConfiguration",
153 schedule: Schedule.max([Schedule.fixed("1 second"), Schedule.recurs(10)]),
154 });
155
156export const StorageLensConfigurationProvider = () =>
157 Provider.effect(
158 StorageLensConfiguration,
159 Effect.gen(function* () {
160 const createConfigId = Effect.fn(function* (
161 id: string,
162 props: Pick<StorageLensConfigurationProps, "configId">,
163 ) {
164 // Config IDs allow [a-zA-Z0-9-_.], 1-64 characters.
165 return (
166 props.configId ?? (yield* createPhysicalName({ id, maxLength: 64 }))
167 );
168 });
169
170 const observeConfiguration = (accountId: string, configId: string) =>
171 s3control
172 .getStorageLensConfiguration({
173 AccountId: accountId,
174 ConfigId: configId,
175 })
176 .pipe(
177 Effect.map((r) => r.StorageLensConfiguration),
178 Effect.catchTag("NoSuchConfiguration", () =>
179 Effect.succeed(undefined),
180 ),
181 );
182
183 const observedTags = (accountId: string, configId: string) =>
184 s3control
185 .getStorageLensConfigurationTagging({
186 AccountId: accountId,
187 ConfigId: configId,
188 })
189 .pipe(
190 Effect.map((r) =>
191 Object.fromEntries((r.Tags ?? []).map((t) => [t.Key, t.Value])),
192 ),
193 Effect.catchTag("NoSuchConfiguration", () =>
194 Effect.succeed({} as Record<string, string>),
195 ),
196 );
197
198 const desiredConfiguration = (
199 configId: string,
200 props: StorageLensConfigurationProps,
201 ): s3control.StorageLensConfiguration => ({
202 Id: configId,
203 AccountLevel: props.accountLevel ?? { BucketLevel: {} },
204 Include: props.include,
205 Exclude: props.exclude,
206 DataExport: props.dataExport,
207 IsEnabled: props.isEnabled ?? true,
208 AwsOrg: props.awsOrg ? { Arn: props.awsOrg } : undefined,
209 });
210
211 // Canonical form for observed-vs-desired comparison: only the members
212 // we manage, with `undefined` members normalized away.
213 const canon = (cfg: s3control.StorageLensConfiguration) =>
214 JSON.stringify({
215 id: cfg.Id,
216 // AWS omits an empty <BucketLevel/> from GET responses, so
217 // normalize it to {} to avoid perpetual drift.
218 accountLevel: {
219 ...cfg.AccountLevel,
220 BucketLevel: cfg.AccountLevel?.BucketLevel ?? {},
221 },
222 include: cfg.Include ?? null,
223 exclude: cfg.Exclude ?? null,
224 dataExport: cfg.DataExport ?? null,
225 isEnabled: cfg.IsEnabled,
226 awsOrg: cfg.AwsOrg ?? null,
227 });
228
229 return StorageLensConfiguration.Provider.of({
230 stables: ["configId", "storageLensArn"],
231 list: () =>
232 Effect.gen(function* () {
233 const { accountId } = yield* AWSEnvironment.current;
234 const pages = yield* s3control.listStorageLensConfigurations
235 .pages({ AccountId: accountId })
236 .pipe(Stream.runCollect);
237 return Array.from(pages).flatMap((page) =>
238 (page.StorageLensConfigurationList ?? []).map((entry) => ({
239 configId: entry.Id,
240 storageLensArn: entry.StorageLensArn,
241 })),
242 );
243 }),
244 read: Effect.fn(function* ({ id, olds, output }) {
245 const { accountId } = yield* AWSEnvironment.current;
246 const configId =
247 output?.configId ?? (yield* createConfigId(id, olds ?? {}));
248 const observed = yield* observeConfiguration(accountId, configId);
249 if (observed?.StorageLensArn === undefined) return undefined;
250 const attrs = {
251 configId,
252 storageLensArn: observed.StorageLensArn,
253 };
254 const tags = yield* observedTags(accountId, configId);
255 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
256 }),
257 diff: Effect.fn(function* ({ id, news, olds }) {
258 if (!isResolved(news)) return undefined;
259 const oldConfigId = yield* createConfigId(id, olds ?? {});
260 const newConfigId = yield* createConfigId(id, news);
261 if (oldConfigId !== newConfigId) {
262 return { action: "replace" } as const;
263 }
264 // fall through: everything else converges via PUT (upsert)
265 }),
266 reconcile: Effect.fn(function* ({ id, news, output, session }) {
267 const { accountId } = yield* AWSEnvironment.current;
268 const configId =
269 output?.configId ?? (yield* createConfigId(id, news));
270
271 // 1. OBSERVE — cloud state is authoritative.
272 const observed = yield* observeConfiguration(accountId, configId);
273
274 // 2. ENSURE + SYNC — PutStorageLensConfiguration is a true upsert;
275 // write only when the observed configuration differs.
276 const desired = desiredConfiguration(configId, news);
277 if (observed === undefined || canon(observed) !== canon(desired)) {
278 yield* s3control.putStorageLensConfiguration({
279 AccountId: accountId,
280 ConfigId: configId,
281 StorageLensConfiguration: desired,
282 });
283 }
284
285 // 3. SYNC TAGS — PutStorageLensConfigurationTagging replaces the
286 // whole tag set, so diff against OBSERVED tags only to decide
287 // whether a write is needed.
288 const internalTags = yield* createInternalTags(id);
289 const desiredTags = { ...news.tags, ...internalTags };
290 const currentTags = yield* observedTags(accountId, configId);
291 const { upsert, removed } = diffTags(currentTags, desiredTags);
292 if (upsert.length > 0 || removed.length > 0) {
293 yield* retryWhileConfigurationPropagates(
294 s3control.putStorageLensConfigurationTagging({
295 AccountId: accountId,
296 ConfigId: configId,
297 Tags: createTagsList(desiredTags),
298 }),
299 );
300 }
301
302 // 4. RETURN — re-read for the service-assigned ARN.
303 const final = yield* retryWhileConfigurationPropagates(
304 s3control.getStorageLensConfiguration({
305 AccountId: accountId,
306 ConfigId: configId,
307 }),
308 );
309
310 yield* session.note(configId);
311 return {
312 configId,
313 storageLensArn:
314 final.StorageLensConfiguration?.StorageLensArn ??
315 output?.storageLensArn ??
316 "",
317 };
318 }),
319 delete: Effect.fn(function* ({ output }) {
320 const { accountId } = yield* AWSEnvironment.current;
321 yield* s3control
322 .deleteStorageLensConfiguration({
323 AccountId: accountId,
324 ConfigId: output.configId,
325 })
326 .pipe(
327 // Idempotent delete — already gone is success.
328 Effect.catchTag("NoSuchConfiguration", () => Effect.void),
329 );
330 }),
331 });
332 }),
333 );