1import * as devopsguru from "@distilled.cloud/aws/devops-guru";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9
10export interface TagAppBoundary {
11 /**
12 * Tag key that defines an application boundary. DevOps Guru requires the
13 * key to begin with `devops-guru-` (any casing, e.g.
14 * `DevOps-Guru-deployment-application`).
15 */
16 appBoundaryKey: string;
17 /**
18 * Tag values that select the resources to analyze. Use `["*"]` to match
19 * every resource carrying the key.
20 */
21 tagValues: string[];
22}
23
24export interface ResourceCollectionProps {
25 /**
26 * Analyze resources belonging to the named CloudFormation stacks. Use
27 * `{ stackNames: ["*"] }` to analyze every stack in the account. Mutually
28 * exclusive with `tags` — an account's collection uses one filter type at
29 * a time.
30 */
31 cloudFormation?: {
32 /** CloudFormation stack names to analyze (`["*"]` for all stacks). */
33 stackNames: string[];
34 };
35 /**
36 * Analyze resources carrying the given app-boundary tags. Tag keys must
37 * begin with `devops-guru-` (any casing). Mutually exclusive with
38 * `cloudFormation`.
39 */
40 tags?: TagAppBoundary[];
41}
42
43export interface ResourceCollection extends Resource<
44 "AWS.DevOpsGuru.ResourceCollection",
45 ResourceCollectionProps,
46 {
47 /** CloudFormation stack names DevOps Guru analyzes. */
48 cloudFormationStackNames: string[];
49 /** App-boundary tag filters DevOps Guru analyzes. */
50 tags: TagAppBoundary[];
51 },
52 never,
53 Providers
54> {}
55
56/**
57 * The DevOps Guru resource collection — the account/region singleton that
58 * defines which AWS resources DevOps Guru analyzes for operational insights,
59 * either by CloudFormation stack or by app-boundary tag. Configuring a
60 * collection is what "enables" DevOps Guru analysis in an account.
61 *
62 * An account has exactly one collection, so this resource is a
63 * capture-and-restore singleton: adopting a collection that Alchemy did not
64 * configure requires `--adopt`.
65 *
66 * ### Defining Coverage
67 * **Example:** Analyze specific CloudFormation stacks
68 * ```typescript
69 * const collection = yield* DevOpsGuru.ResourceCollection("Coverage", {
70 * cloudFormation: { stackNames: ["my-app-prod"] },
71 * });
72 * ```
73 *
74 * **Example:** Analyze every stack in the account
75 * ```typescript
76 * const collection = yield* DevOpsGuru.ResourceCollection("Coverage", {
77 * cloudFormation: { stackNames: ["*"] },
78 * });
79 * ```
80 *
81 * **Example:** Analyze resources by app-boundary tag
82 * ```typescript
83 * const collection = yield* DevOpsGuru.ResourceCollection("Coverage", {
84 * tags: [
85 * { appBoundaryKey: "devops-guru-app", tagValues: ["checkout", "billing"] },
86 * ],
87 * });
88 * ```
89 *
90 * @resource
91 */
92export const ResourceCollection = Resource<ResourceCollection>(
93 "AWS.DevOpsGuru.ResourceCollection",
94);
95
96/**
97 * Concurrent `UpdateResourceCollection` calls conflict server-side — retry
98 * the typed conflict tag on a short bounded schedule.
99 *
100 * Explicitly typed: inlining `Effect.retry` with options in provider
101 * lifecycle code can widen the provider layer to `unknown` in declaration
102 * emit.
103 *
104 * @internal
105 */
106const retryUpdateConflict = <A, E extends { readonly _tag: string }, R>(
107 self: Effect.Effect<A, E, R>,
108): Effect.Effect<A, E, R> =>
109 Effect.retry(self, {
110 while: (e) => e._tag === "ConflictException",
111 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(8)]),
112 });
113
114const sameKey = (left: string, right: string) =>
115 left.toLowerCase() === right.toLowerCase();
116
117export const ResourceCollectionProvider = () =>
118 Provider.effect(
119 ResourceCollection,
120 Effect.gen(function* () {
121 // GetResourceCollection fails with a typed ResourceNotFoundException
122 // ("No CustomerResourceFilter present") when nothing has ever been
123 // configured — treat that as an empty collection.
124 const observeType = Effect.fn(function* (
125 type: devopsguru.ResourceCollectionType,
126 ) {
127 return yield* devopsguru.getResourceCollection
128 .pages({ ResourceCollectionType: type })
129 .pipe(
130 Stream.runCollect,
131 Effect.map((chunk) => Array.from(chunk)),
132 Effect.catchTag("ResourceNotFoundException", () =>
133 Effect.succeed([] as devopsguru.GetResourceCollectionResponse[]),
134 ),
135 );
136 });
137
138 // Observe the full account collection (both filter types).
139 const observe = Effect.gen(function* () {
140 const cfnPages = yield* observeType("AWS_CLOUD_FORMATION");
141 const tagPages = yield* observeType("AWS_TAGS");
142 const stackNames = [
143 ...new Set(
144 cfnPages.flatMap(
145 (page) =>
146 page.ResourceCollection?.CloudFormation?.StackNames ?? [],
147 ),
148 ),
149 ].sort();
150 const tagMap = new Map<string, Set<string>>();
151 for (const page of tagPages) {
152 for (const tag of page.ResourceCollection?.Tags ?? []) {
153 const values = tagMap.get(tag.AppBoundaryKey) ?? new Set<string>();
154 for (const value of tag.TagValues) values.add(value);
155 tagMap.set(tag.AppBoundaryKey, values);
156 }
157 }
158 // AWS keeps an app-boundary key with an empty TagValues list after
159 // its last value is removed — a key selecting no values analyzes
160 // nothing, so it is treated as absent.
161 const tags = [...tagMap.entries()]
162 .filter(([, values]) => values.size > 0)
163 .map(([appBoundaryKey, values]) => ({
164 appBoundaryKey,
165 tagValues: [...values].sort(),
166 }))
167 .sort((l, r) => l.appBoundaryKey.localeCompare(r.appBoundaryKey));
168 return { stackNames, tags };
169 });
170
171 const update = Effect.fn(function* (
172 action: devopsguru.UpdateResourceCollectionAction,
173 filter: devopsguru.UpdateResourceCollectionFilter,
174 ) {
175 yield* retryUpdateConflict(
176 devopsguru.updateResourceCollection({
177 Action: action,
178 ResourceCollection: filter,
179 }),
180 );
181 });
182
183 const toAttrs = (observed: {
184 stackNames: string[];
185 tags: TagAppBoundary[];
186 }) => ({
187 cloudFormationStackNames: observed.stackNames,
188 tags: observed.tags,
189 });
190
191 return {
192 // Account/region singleton — the one collection, when configured.
193 list: () =>
194 observe.pipe(
195 Effect.map((observed) =>
196 observed.stackNames.length > 0 || observed.tags.length > 0
197 ? [toAttrs(observed)]
198 : [],
199 ),
200 ),
201
202 read: Effect.fn(function* ({ output }) {
203 const observed = yield* observe;
204 if (observed.stackNames.length === 0 && observed.tags.length === 0) {
205 return undefined;
206 }
207 const attrs = toAttrs(observed);
208 // The collection can't carry ownership tags — a configured
209 // collection we have no record of belongs to someone else until
210 // explicitly adopted.
211 return output !== undefined ? attrs : Unowned(attrs);
212 }),
213
214 reconcile: Effect.fn(function* ({ news, session }) {
215 const desiredStacks = news.cloudFormation?.stackNames ?? [];
216 const desiredTags = news.tags ?? [];
217
218 // 1. OBSERVE — the live account collection is authoritative.
219 const observed = yield* observe;
220
221 // 2. SYNC (removals first) — the two filter types are mutually
222 // exclusive, so pruning before adding also handles switching
223 // between CloudFormation- and tag-based coverage.
224 const stacksToRemove = observed.stackNames.filter(
225 (name) => !desiredStacks.includes(name),
226 );
227 if (stacksToRemove.length > 0) {
228 yield* update("REMOVE", {
229 CloudFormation: { StackNames: stacksToRemove },
230 });
231 }
232 for (const tag of observed.tags) {
233 const desired = desiredTags.find((t) =>
234 sameKey(t.appBoundaryKey, tag.appBoundaryKey),
235 );
236 const valuesToRemove = tag.tagValues.filter(
237 (value) => !(desired?.tagValues ?? []).includes(value),
238 );
239 if (valuesToRemove.length > 0) {
240 yield* update("REMOVE", {
241 Tags: [
242 {
243 AppBoundaryKey: tag.appBoundaryKey,
244 TagValues: valuesToRemove,
245 },
246 ],
247 });
248 }
249 }
250
251 // 3. SYNC (additions) — apply only the delta.
252 const stacksToAdd = desiredStacks.filter(
253 (name) => !observed.stackNames.includes(name),
254 );
255 if (stacksToAdd.length > 0) {
256 yield* update("ADD", {
257 CloudFormation: { StackNames: stacksToAdd },
258 });
259 }
260 for (const desired of desiredTags) {
261 const current = observed.tags.find((t) =>
262 sameKey(t.appBoundaryKey, desired.appBoundaryKey),
263 );
264 const valuesToAdd = desired.tagValues.filter(
265 (value) => !(current?.tagValues ?? []).includes(value),
266 );
267 if (valuesToAdd.length > 0) {
268 yield* update("ADD", {
269 Tags: [
270 {
271 AppBoundaryKey: desired.appBoundaryKey,
272 TagValues: valuesToAdd,
273 },
274 ],
275 });
276 }
277 }
278
279 // 4. RETURN fresh attributes.
280 const final = yield* observe;
281 yield* session.note(
282 final.stackNames.length > 0
283 ? `stacks: ${final.stackNames.join(", ")}`
284 : `tags: ${final.tags.map((t) => t.appBoundaryKey).join(", ")}`,
285 );
286 return toAttrs(final);
287 }),
288
289 // Deleting the collection removes every observed filter, returning
290 // the account to "DevOps Guru analyzes nothing".
291 delete: Effect.fn(function* () {
292 const observed = yield* observe;
293 if (observed.stackNames.length > 0) {
294 yield* update("REMOVE", {
295 CloudFormation: { StackNames: observed.stackNames },
296 });
297 }
298 for (const tag of observed.tags) {
299 yield* update("REMOVE", {
300 Tags: [
301 {
302 AppBoundaryKey: tag.appBoundaryKey,
303 TagValues: tag.tagValues,
304 },
305 ],
306 });
307 }
308 }),
309 };
310 }),
311 );