1import * as oam from "@distilled.cloud/aws/oam";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { hasAlchemyTags } from "../../Tags.ts";
8import type { Providers } from "../Providers.ts";
9import {
10 deleteLinkAndWait,
11 readOamTags,
12 retryOamMutation,
13 syncOamTags,
14} from "./internal.ts";
15
16/**
17 * The telemetry resource types that can be shared over a link.
18 */
19export type LinkResourceType =
20 | "AWS::CloudWatch::Metric"
21 | "AWS::Logs::LogGroup"
22 | "AWS::XRay::Trace"
23 | "AWS::ApplicationInsights::Application"
24 | "AWS::InternetMonitor::Monitor"
25 | "AWS::ApplicationSignals::Service"
26 | "AWS::ApplicationSignals::ServiceLevelObjective"
27 | (string & {});
28
29export interface LinkProps {
30 /**
31 * How the link's label appears in the monitoring account. Variables:
32 * `$AccountName`, `$AccountEmail`, `$AccountEmailNoDomain`.
33 * Changing the label template replaces the link.
34 */
35 labelTemplate: string;
36
37 /**
38 * The telemetry resource types shared from this source account to the
39 * monitoring account, e.g. `AWS::CloudWatch::Metric`,
40 * `AWS::Logs::LogGroup`, `AWS::XRay::Trace`.
41 */
42 resourceTypes: LinkResourceType[];
43
44 /**
45 * The ARN of the monitoring-account sink to attach to. The sink's policy
46 * must permit this account to link. Changing the sink replaces the link.
47 * Must be in a **different** account — OAM rejects a link to a sink in
48 * the same account.
49 */
50 sinkIdentifier: string;
51
52 /**
53 * Optional filters restricting which log groups and metric namespaces
54 * are shared to the monitoring account.
55 */
56 linkConfiguration?: {
57 /**
58 * Filter (OAM filter syntax) selecting which log groups are shared,
59 * e.g. `LogGroupName LIKE 'aws/lambda/%'`.
60 */
61 logGroupConfiguration?: { filter: string };
62 /**
63 * Filter selecting which metric namespaces are shared, e.g.
64 * `Namespace NOT LIKE 'AWS/%'`.
65 */
66 metricConfiguration?: { filter: string };
67 };
68
69 /**
70 * User tags to attach to the link. Merged with internal Alchemy tags.
71 */
72 tags?: Record<string, string>;
73}
74
75export interface Link extends Resource<
76 "AWS.OAM.Link",
77 LinkProps,
78 {
79 /** The ARN of the link. */
80 linkArn: string;
81 /** The random ID string that AWS generated as part of the link ARN. */
82 linkId: string;
83 /** The label that this link displays in the monitoring account. */
84 label: string;
85 /** The ARN of the sink this link is attached to. */
86 sinkArn: string;
87 },
88 never,
89 Providers
90> {}
91
92/**
93 * A CloudWatch cross-account observability **link** — created in a source
94 * account, it attaches to a monitoring-account {@link Sink} and shares the
95 * selected telemetry types (metrics, log groups, traces, Application
96 * Signals) with that account.
97 *
98 * The sink must live in a **different** account and its sink policy must
99 * authorize this account to link.
100 *
101 * ### Creating a Link
102 * **Example:** Share metrics and logs with a monitoring account
103 * ```typescript
104 * import * as OAM from "alchemy/AWS/OAM";
105 *
106 * const link = yield* OAM.Link("ToMonitoring", {
107 * labelTemplate: "$AccountName",
108 * resourceTypes: ["AWS::CloudWatch::Metric", "AWS::Logs::LogGroup"],
109 * sinkIdentifier:
110 * "arn:aws:oam:us-west-2:111122223333:sink/1c72e9ec-4d4a-4e...",
111 * });
112 * ```
113 *
114 * **Example:** Filter what is shared
115 * ```typescript
116 * const link = yield* OAM.Link("FilteredLink", {
117 * labelTemplate: "$AccountName",
118 * resourceTypes: ["AWS::CloudWatch::Metric", "AWS::Logs::LogGroup"],
119 * sinkIdentifier: sinkArn,
120 * linkConfiguration: {
121 * logGroupConfiguration: { filter: "LogGroupName LIKE 'aws/lambda/%'" },
122 * metricConfiguration: { filter: "Namespace NOT LIKE 'AWS/%'" },
123 * },
124 * });
125 * ```
126 *
127 * @resource
128 */
129export const Link = Resource<Link>("AWS.OAM.Link");
130
131const toLinkConfiguration = (
132 config: LinkProps["linkConfiguration"],
133): oam.LinkConfiguration | undefined =>
134 config === undefined
135 ? undefined
136 : {
137 ...(config.logGroupConfiguration
138 ? {
139 LogGroupConfiguration: {
140 Filter: config.logGroupConfiguration.filter,
141 },
142 }
143 : {}),
144 ...(config.metricConfiguration
145 ? {
146 MetricConfiguration: {
147 Filter: config.metricConfiguration.filter,
148 },
149 }
150 : {}),
151 };
152
153const sameResourceTypes = (
154 a: readonly string[] | undefined,
155 b: readonly string[] | undefined,
156): boolean =>
157 JSON.stringify([...(a ?? [])].sort()) ===
158 JSON.stringify([...(b ?? [])].sort());
159
160const sameLinkConfiguration = (
161 a: oam.LinkConfiguration | undefined,
162 b: oam.LinkConfiguration | undefined,
163): boolean =>
164 (a?.LogGroupConfiguration?.Filter ?? null) ===
165 (b?.LogGroupConfiguration?.Filter ?? null) &&
166 (a?.MetricConfiguration?.Filter ?? null) ===
167 (b?.MetricConfiguration?.Filter ?? null);
168
169export const LinkProvider = () =>
170 Provider.effect(
171 Link,
172 Effect.gen(function* () {
173 const getLinkByArn = Effect.fn(function* (linkArn: string) {
174 return yield* oam
175 .getLink({ Identifier: linkArn })
176 .pipe(
177 Effect.catchTag("ResourceNotFoundException", () =>
178 Effect.succeed(undefined),
179 ),
180 );
181 });
182
183 return Link.Provider.of({
184 stables: ["linkArn", "linkId", "label", "sinkArn"],
185 list: () =>
186 oam.listLinks.pages({}).pipe(
187 Stream.runCollect,
188 Effect.map((chunk) =>
189 Array.from(chunk).flatMap((page) =>
190 page.Items.filter(
191 (item) => item.Arn != null && item.Id != null,
192 ).map((item) => ({
193 linkArn: item.Arn!,
194 linkId: item.Id!,
195 label: item.Label ?? "",
196 sinkArn: item.SinkArn ?? "",
197 })),
198 ),
199 ),
200 ),
201 read: Effect.fn(function* ({ id, output }) {
202 // Links are identified by an auto-assigned ARN. With a cached ARN
203 // we look it up directly; without one (state persistence failed
204 // before the ARN landed) we scan the account's links for the one
205 // carrying our ownership tags.
206 if (output?.linkArn) {
207 const found = yield* getLinkByArn(output.linkArn);
208 if (!found?.Arn) return undefined;
209 return {
210 linkArn: found.Arn,
211 linkId: found.Id!,
212 label: found.Label ?? "",
213 sinkArn: found.SinkArn ?? "",
214 };
215 }
216 const pages = yield* oam.listLinks.pages({}).pipe(Stream.runCollect);
217 const items = Array.from(pages).flatMap((page) => page.Items);
218 for (const item of items) {
219 if (item.Arn == null) continue;
220 const tags = yield* readOamTags(item.Arn);
221 if (yield* hasAlchemyTags(id, tags)) {
222 return {
223 linkArn: item.Arn,
224 linkId: item.Id!,
225 label: item.Label ?? "",
226 sinkArn: item.SinkArn ?? "",
227 };
228 }
229 }
230 return undefined;
231 }),
232 diff: Effect.fn(function* ({ news, olds }) {
233 if (!isResolved(news)) return undefined;
234 // The label template and the target sink are immutable —
235 // UpdateLink only changes resource types and filters.
236 if (news.labelTemplate !== olds.labelTemplate) {
237 return { action: "replace" } as const;
238 }
239 if (news.sinkIdentifier !== olds.sinkIdentifier) {
240 return { action: "replace" } as const;
241 }
242 return undefined;
243 }),
244 reconcile: Effect.fn(function* ({ id, news, output, session }) {
245 const desiredConfiguration = toLinkConfiguration(
246 news.linkConfiguration,
247 );
248
249 // OBSERVE — the cached ARN is only a hint; a deleted link falls
250 // through to create.
251 let live = output?.linkArn
252 ? yield* getLinkByArn(output.linkArn)
253 : undefined;
254
255 // ENSURE
256 if (live?.Arn == null) {
257 live = yield* retryOamMutation(
258 oam.createLink({
259 LabelTemplate: news.labelTemplate,
260 ResourceTypes: news.resourceTypes,
261 SinkIdentifier: news.sinkIdentifier,
262 LinkConfiguration: desiredConfiguration,
263 Tags: news.tags,
264 }),
265 );
266 }
267 const linkArn = live!.Arn!;
268
269 // SYNC — resource types + filters, diffed against observed state.
270 if (
271 !sameResourceTypes(live!.ResourceTypes, news.resourceTypes) ||
272 !sameLinkConfiguration(
273 live!.LinkConfiguration,
274 desiredConfiguration,
275 )
276 ) {
277 live = yield* retryOamMutation(
278 oam.updateLink({
279 Identifier: linkArn,
280 ResourceTypes: news.resourceTypes,
281 LinkConfiguration: desiredConfiguration,
282 }),
283 );
284 }
285
286 // SYNC tags — against observed cloud tags (adoption-safe).
287 yield* syncOamTags(linkArn, id, news.tags);
288
289 yield* session.note(linkArn);
290 return {
291 linkArn,
292 linkId: live!.Id!,
293 label: live!.Label ?? "",
294 sinkArn: live!.SinkArn ?? "",
295 };
296 }),
297 delete: Effect.fn(function* ({ output }) {
298 yield* deleteLinkAndWait(output.linkArn);
299 }),
300 });
301 }),
302 );