| 1 | import * as oam from "@distilled.cloud/aws/oam"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Stream from "effect/Stream"; |
| 4 | import { isResolved } from "../../Diff.ts"; |
| 5 | import * as Provider from "../../Provider.ts"; |
| 6 | import { Resource } from "../../Resource.ts"; |
| 7 | import { hasAlchemyTags } from "../../Tags.ts"; |
| 8 | import type { Providers } from "../Providers.ts"; |
| 9 | import { |
| 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 | */ |
| 19 | export 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 | |
| 29 | export 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 | |
| 75 | export 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 | */ |
| 129 | export const Link = Resource<Link>("AWS.OAM.Link"); |
| 130 | |
| 131 | const 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 | |
| 153 | const sameResourceTypes = ( |
| 154 | a: readonly string[] | undefined, |
| 155 | b: readonly string[] | undefined, |
| 156 | ): boolean => |
| 157 | JSON.stringify([...(a ?? [])].sort()) === |
| 158 | JSON.stringify([...(b ?? [])].sort()); |
| 159 | |
| 160 | const 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 | |
| 169 | export 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 | ); |