1import * as detective from "@distilled.cloud/aws/detective";
2import * as Effect from "effect/Effect";
3import { Unowned } from "../../AdoptPolicy.ts";
4import * as Provider from "../../Provider.ts";
5import { Resource } from "../../Resource.ts";
6import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
7import type { Providers } from "../Providers.ts";
8
9export interface GraphProps {
10 /**
11 * Tags applied to the behavior graph. Alchemy ownership tags are merged in
12 * automatically so the graph can be recognized on subsequent runs.
13 */
14 tags?: Record<string, string>;
15}
16
17/** @resource */
18export interface Graph extends Resource<
19 "AWS.Detective.Graph",
20 GraphProps,
21 {
22 /** ARN of the Detective behavior graph. */
23 graphArn: string;
24 /** ISO timestamp of when the graph was created. */
25 createdTime: string | undefined;
26 },
27 never,
28 Providers
29> {}
30
31/**
32 * A Detective behavior graph — the account/region singleton that enables Amazon
33 * Detective. An account can have at most one behavior graph per region, so this
34 * resource is a capture-and-restore singleton: adopting a pre-existing graph
35 * that Alchemy did not create requires `--adopt`.
36 *
37 * ### Enabling Detective
38 * **Example:** Enable a behavior graph
39 * ```typescript
40 * const graph = yield* Detective.Graph("Graph", {});
41 * ```
42 *
43 * **Example:** Enable with tags
44 * ```typescript
45 * const graph = yield* Detective.Graph("Graph", {
46 * tags: { team: "security" },
47 * });
48 * ```
49 */
50const GraphResource = Resource<Graph>("AWS.Detective.Graph");
51
52export { GraphResource as Graph };
53
54const buildAttrs = (g: detective.Graph) => ({
55 graphArn: g.Arn!,
56 createdTime: g.CreatedTime?.toISOString(),
57});
58
59export const GraphProvider = () =>
60 Provider.effect(
61 GraphResource,
62 Effect.gen(function* () {
63 // Detective allows at most one behavior graph per account/region. Return
64 // the first (only) graph, if any.
65 const firstGraph = detective
66 .listGraphs({})
67 .pipe(Effect.map((r) => r.GraphList?.[0]));
68
69 const readTags = (arn: string) =>
70 detective.listTagsForResource({ ResourceArn: arn }).pipe(
71 Effect.map((r) => (r.Tags ?? {}) as Record<string, string>),
72 Effect.catch(() => Effect.succeed({} as Record<string, string>)),
73 );
74
75 return {
76 read: Effect.fn(function* ({ id, output }) {
77 const graph = yield* firstGraph;
78 if (!graph?.Arn) return undefined;
79 // If we have a cached ARN and it no longer matches the single graph,
80 // the graph we knew about is gone.
81 if (output?.graphArn && output.graphArn !== graph.Arn) {
82 return undefined;
83 }
84 const attrs = buildAttrs(graph);
85 const tags = yield* readTags(graph.Arn);
86 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
87 }),
88
89 // Account/region singleton — enumerate the single behavior graph.
90 list: () =>
91 detective
92 .listGraphs({})
93 .pipe(Effect.map((r) => (r.GraphList ?? []).map(buildAttrs))),
94
95 reconcile: Effect.fn(function* ({ id, news = {}, session }) {
96 const internalTags = yield* createInternalTags(id);
97 const desiredTags = { ...news.tags, ...internalTags };
98
99 // 1. OBSERVE — cloud state is authoritative.
100 let graph = yield* firstGraph;
101
102 // 2. ENSURE — create the graph (with tags inline) if none exists.
103 if (!graph?.Arn) {
104 const created = yield* detective.createGraph({
105 Tags: desiredTags,
106 });
107 graph = { Arn: created.GraphArn! };
108 }
109
110 const arn = graph.Arn!;
111
112 // 3. SYNC tags — diff against OBSERVED cloud tags.
113 const currentTags = yield* readTags(arn);
114 const { upsert, removed } = diffTags(currentTags, desiredTags);
115 if (upsert.length > 0) {
116 yield* detective.tagResource({
117 ResourceArn: arn,
118 Tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
119 });
120 }
121 if (removed.length > 0) {
122 yield* detective.untagResource({
123 ResourceArn: arn,
124 TagKeys: removed,
125 });
126 }
127
128 // 4. RETURN fresh attributes.
129 const final = yield* firstGraph;
130 yield* session.note(arn);
131 return final?.Arn
132 ? buildAttrs(final)
133 : { graphArn: arn, createdTime: undefined };
134 }),
135
136 delete: Effect.fn(function* ({ output }) {
137 yield* detective
138 .deleteGraph({ GraphArn: output.graphArn })
139 .pipe(
140 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
141 );
142 }),
143 };
144 }),
145 );