| 1 | import * as monitoring from "@distilled.cloud/gcp/monitoring_v3"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Stream from "effect/Stream"; |
| 5 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 6 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { Resource } from "../../Resource.ts"; |
| 9 | import { tagRecord } from "../../Tags.ts"; |
| 10 | import { GcpEnvironment } from "../Environment.ts"; |
| 11 | import { |
| 12 | createInternalLabels, |
| 13 | diffLabels, |
| 14 | hasAlchemyLabels, |
| 15 | stripInternalLabels, |
| 16 | toLabels, |
| 17 | } from "../Labels.ts"; |
| 18 | import type { Providers } from "../Providers.ts"; |
| 19 | import { compactStringMap } from "./ownership.ts"; |
| 20 | |
| 21 | const MAX_DISPLAY_NAME_LENGTH = 512; |
| 22 | const DEFAULT_COMBINER = "OR"; |
| 23 | |
| 24 | export type AlertPolicyCombiner = |
| 25 | | "AND" |
| 26 | | "OR" |
| 27 | | "AND_WITH_MATCHING_RESOURCE" |
| 28 | | (string & {}); |
| 29 | |
| 30 | export type AlertPolicySeverity = |
| 31 | | "CRITICAL" |
| 32 | | "ERROR" |
| 33 | | "WARNING" |
| 34 | | (string & {}); |
| 35 | |
| 36 | export type Aggregation = { |
| 37 | /** |
| 38 | * Alignment window (for example `"60s"`). Required when a per-series |
| 39 | * aligner other than `ALIGN_NONE` is set. Minimum 60 seconds. |
| 40 | */ |
| 41 | alignmentPeriod?: string; |
| 42 | /** |
| 43 | * Per-series aligner (`ALIGN_MEAN`, `ALIGN_RATE`, `ALIGN_MAX`, …). |
| 44 | */ |
| 45 | perSeriesAligner?: string; |
| 46 | /** |
| 47 | * Cross-series reducer (`REDUCE_MEAN`, `REDUCE_SUM`, …). |
| 48 | */ |
| 49 | crossSeriesReducer?: string; |
| 50 | /** |
| 51 | * Fields to preserve when reducing. `resource.type` is always kept. |
| 52 | */ |
| 53 | groupByFields?: string[]; |
| 54 | }; |
| 55 | |
| 56 | export type Trigger = { |
| 57 | /** Absolute number of time series that must fail. */ |
| 58 | count?: number; |
| 59 | /** Percentage of time series that must fail. */ |
| 60 | percent?: number; |
| 61 | }; |
| 62 | |
| 63 | export type ForecastOptions = { |
| 64 | /** |
| 65 | * How far ahead to forecast a threshold violation (1h–60h). |
| 66 | */ |
| 67 | forecastHorizon?: string; |
| 68 | }; |
| 69 | |
| 70 | export type MetricThreshold = { |
| 71 | /** |
| 72 | * Monitoring filter identifying the time series (must include metric |
| 73 | * type and resource type). |
| 74 | */ |
| 75 | filter?: string; |
| 76 | /** |
| 77 | * Alignments applied in order to the time series selected by `filter`. |
| 78 | */ |
| 79 | aggregations?: Aggregation[]; |
| 80 | /** |
| 81 | * Denominator filter when comparing a ratio against the threshold. |
| 82 | */ |
| 83 | denominatorFilter?: string; |
| 84 | /** |
| 85 | * Alignments applied to the denominator time series. |
| 86 | */ |
| 87 | denominatorAggregations?: Aggregation[]; |
| 88 | /** |
| 89 | * Comparison (`COMPARISON_GT` or `COMPARISON_LT`). |
| 90 | */ |
| 91 | comparison?: string; |
| 92 | /** |
| 93 | * Threshold the time series is compared against. |
| 94 | */ |
| 95 | thresholdValue?: number; |
| 96 | /** |
| 97 | * How long the series must violate the threshold. Must be a multiple |
| 98 | * of 60 seconds. |
| 99 | */ |
| 100 | duration?: string; |
| 101 | /** |
| 102 | * How many (or what percent of) series must fail. Defaults to count 1. |
| 103 | */ |
| 104 | trigger?: Trigger; |
| 105 | /** |
| 106 | * Behavior when data stops arriving (`EVALUATION_MISSING_DATA_INACTIVE`, |
| 107 | * `EVALUATION_MISSING_DATA_ACTIVE`, `EVALUATION_MISSING_DATA_NO_OP`). |
| 108 | */ |
| 109 | evaluationMissingData?: string; |
| 110 | /** |
| 111 | * When set, the condition forecasts whether the series will violate |
| 112 | * the threshold within `forecastHorizon`. |
| 113 | */ |
| 114 | forecastOptions?: ForecastOptions; |
| 115 | }; |
| 116 | |
| 117 | export type MetricAbsence = { |
| 118 | /** |
| 119 | * Monitoring filter identifying the time series. |
| 120 | */ |
| 121 | filter?: string; |
| 122 | /** |
| 123 | * Alignments applied in order. |
| 124 | */ |
| 125 | aggregations?: Aggregation[]; |
| 126 | /** |
| 127 | * How long the series must fail to report data. Minimum 120 seconds. |
| 128 | */ |
| 129 | duration?: string; |
| 130 | /** |
| 131 | * How many (or what percent of) series must fail. Defaults to count 1. |
| 132 | */ |
| 133 | trigger?: Trigger; |
| 134 | }; |
| 135 | |
| 136 | export type LogMatch = { |
| 137 | /** |
| 138 | * Advanced logs filter. Only logs in the scoping project are evaluated. |
| 139 | */ |
| 140 | filter?: string; |
| 141 | /** |
| 142 | * Map from label key to an extractor expression. |
| 143 | */ |
| 144 | labelExtractors?: Record<string, string>; |
| 145 | }; |
| 146 | |
| 147 | export type MonitoringQueryLanguageCondition = { |
| 148 | /** MQL query that outputs a boolean stream. */ |
| 149 | query?: string; |
| 150 | /** |
| 151 | * How long the series must violate the condition. Multiple of 60s. |
| 152 | */ |
| 153 | duration?: string; |
| 154 | /** |
| 155 | * Behavior when data stops arriving. |
| 156 | */ |
| 157 | evaluationMissingData?: string; |
| 158 | /** How many (or what percent of) series must fail. */ |
| 159 | trigger?: Trigger; |
| 160 | }; |
| 161 | |
| 162 | export type PrometheusQueryLanguageCondition = { |
| 163 | /** PromQL expression. Required. */ |
| 164 | query?: string; |
| 165 | /** How long the expression must be true before firing. */ |
| 166 | duration?: string; |
| 167 | /** Evaluation interval. Multiple of 30 seconds. Defaults to 30s. */ |
| 168 | evaluationInterval?: string; |
| 169 | /** Labels added to or overwriting the PromQL result. */ |
| 170 | labels?: Record<string, string>; |
| 171 | /** Rule group name from the originating Prometheus config. */ |
| 172 | ruleGroup?: string; |
| 173 | /** Alerting rule name from the originating Prometheus config. */ |
| 174 | alertRule?: string; |
| 175 | /** |
| 176 | * When true, skip metric-existence validation so policies can be |
| 177 | * created before the metric exists. |
| 178 | */ |
| 179 | disableMetricValidation?: boolean; |
| 180 | }; |
| 181 | |
| 182 | export type SqlCondition = { |
| 183 | /** Log Analytics GoogleSQL query. */ |
| 184 | query?: string; |
| 185 | /** Run every N minutes (5–1440). */ |
| 186 | minutes?: { periodicity?: number }; |
| 187 | /** Run every N hours (1–48). */ |
| 188 | hourly?: { periodicity?: number; minuteOffset?: number }; |
| 189 | /** Run every N days (1–31). */ |
| 190 | daily?: { |
| 191 | periodicity?: number; |
| 192 | executionTime?: { |
| 193 | hours?: number; |
| 194 | minutes?: number; |
| 195 | seconds?: number; |
| 196 | nanos?: number; |
| 197 | }; |
| 198 | }; |
| 199 | /** Compare the result row count against a threshold. */ |
| 200 | rowCountTest?: { comparison?: string; threshold?: string }; |
| 201 | /** Test a boolean column. */ |
| 202 | booleanTest?: { column?: string }; |
| 203 | }; |
| 204 | |
| 205 | export type AlertCondition = { |
| 206 | /** |
| 207 | * Short name shown in dashboards and incidents. Unique within the |
| 208 | * policy. |
| 209 | */ |
| 210 | displayName: string; |
| 211 | /** |
| 212 | * Server-assigned condition name. Omit on create. On update, Alchemy |
| 213 | * preserves observed names so GCP does not recreate conditions. |
| 214 | */ |
| 215 | name?: string; |
| 216 | /** Compare a time series against a threshold. */ |
| 217 | conditionThreshold?: MetricThreshold; |
| 218 | /** Fire when a time series stops reporting data. */ |
| 219 | conditionAbsent?: MetricAbsence; |
| 220 | /** Fire when a log entry matches `filter`. Exclusive of other kinds. */ |
| 221 | conditionMatchedLog?: LogMatch; |
| 222 | /** MQL boolean-stream condition. */ |
| 223 | conditionMonitoringQueryLanguage?: MonitoringQueryLanguageCondition; |
| 224 | /** PromQL alerting rule. */ |
| 225 | conditionPrometheusQueryLanguage?: PrometheusQueryLanguageCondition; |
| 226 | /** Log Analytics SQL condition. */ |
| 227 | conditionSql?: SqlCondition; |
| 228 | }; |
| 229 | |
| 230 | export type DocumentationLink = { |
| 231 | /** Short link title (max 63 characters). */ |
| 232 | displayName?: string; |
| 233 | /** URL. May include alert template variables. */ |
| 234 | url?: string; |
| 235 | }; |
| 236 | |
| 237 | export type Documentation = { |
| 238 | /** Body included in notifications. Markdown. Max 8192 characters. */ |
| 239 | content?: string; |
| 240 | /** |
| 241 | * MIME type of `content`. |
| 242 | * @default "text/markdown" |
| 243 | */ |
| 244 | mimeType?: string; |
| 245 | /** Notification subject line. */ |
| 246 | subject?: string; |
| 247 | /** Up to 3 documentation links. */ |
| 248 | links?: DocumentationLink[]; |
| 249 | }; |
| 250 | |
| 251 | export type AlertStrategy = { |
| 252 | /** Close open incidents after this much missing data. */ |
| 253 | autoClose?: string; |
| 254 | /** Log-based policies only. At most one notification per period. */ |
| 255 | notificationRateLimit?: { period?: string }; |
| 256 | /** |
| 257 | * When to notify (`OPENED`, `CLOSED`). Log-based policies are always |
| 258 | * `OPENED`. |
| 259 | */ |
| 260 | notificationPrompts?: string[]; |
| 261 | /** Per-channel reminder settings. */ |
| 262 | notificationChannelStrategy?: { |
| 263 | renotifyInterval?: string; |
| 264 | notificationChannelNames?: string[]; |
| 265 | }[]; |
| 266 | }; |
| 267 | |
| 268 | export type AlertPolicyProps = { |
| 269 | /** |
| 270 | * Human-readable display name. If omitted, a unique name is generated |
| 271 | * from the stack, stage, and logical id. Limited to 512 Unicode |
| 272 | * characters. |
| 273 | */ |
| 274 | displayName?: string; |
| 275 | /** |
| 276 | * How to combine conditions when more than one is set. |
| 277 | * @default "OR" |
| 278 | */ |
| 279 | combiner?: AlertPolicyCombiner; |
| 280 | /** |
| 281 | * Conditions that open an incident. A policy has 1–6 conditions. |
| 282 | */ |
| 283 | conditions: AlertCondition[]; |
| 284 | /** |
| 285 | * Notification channel resource names |
| 286 | * (`projects/{project}/notificationChannels/{channel}`). |
| 287 | */ |
| 288 | notificationChannels?: string[]; |
| 289 | /** |
| 290 | * Markdown documentation included in notifications and incidents. |
| 291 | */ |
| 292 | documentation?: Documentation; |
| 293 | /** |
| 294 | * Whether the policy is evaluated. |
| 295 | * @default true |
| 296 | */ |
| 297 | enabled?: boolean; |
| 298 | /** |
| 299 | * Incident severity shown in the console and notifications. |
| 300 | */ |
| 301 | severity?: AlertPolicySeverity; |
| 302 | /** |
| 303 | * Notification strategy (auto-close, rate limits, prompts). |
| 304 | */ |
| 305 | alertStrategy?: AlertStrategy; |
| 306 | /** |
| 307 | * User labels. Alchemy ownership labels are merged in automatically. |
| 308 | */ |
| 309 | labels?: Record<string, string>; |
| 310 | }; |
| 311 | |
| 312 | export type AlertPolicy = Resource< |
| 313 | "GCP.Monitoring.AlertPolicy", |
| 314 | AlertPolicyProps, |
| 315 | { |
| 316 | /** Full resource name `projects/{project}/alertPolicies/{policy}`. */ |
| 317 | name: string; |
| 318 | /** Server-assigned policy id (last path segment). */ |
| 319 | alertPolicyId: string; |
| 320 | /** Project id. */ |
| 321 | project: string; |
| 322 | /** Human-readable display name. */ |
| 323 | displayName: string | undefined; |
| 324 | /** Combiner used when the policy has multiple conditions. */ |
| 325 | combiner: string | undefined; |
| 326 | /** Conditions currently configured. */ |
| 327 | conditions: AlertCondition[]; |
| 328 | /** Notification channel resource names. */ |
| 329 | notificationChannels: ReadonlyArray<string>; |
| 330 | /** Documentation included in notifications, if set. */ |
| 331 | documentation: Documentation | undefined; |
| 332 | /** Whether the policy is evaluated. */ |
| 333 | enabled: boolean; |
| 334 | /** Incident severity, if set. */ |
| 335 | severity: string | undefined; |
| 336 | /** Notification strategy, if set. */ |
| 337 | alertStrategy: AlertStrategy | undefined; |
| 338 | /** User labels (Alchemy ownership labels stripped). */ |
| 339 | labels: Record<string, string>; |
| 340 | }, |
| 341 | never, |
| 342 | Providers |
| 343 | >; |
| 344 | |
| 345 | /** |
| 346 | * A Cloud Monitoring alerting policy — conditions that open incidents |
| 347 | * and optional notification channels. |
| 348 | * |
| 349 | * Policy ids are assigned by the API. Alchemy stamps ownership into |
| 350 | * `userLabels` so `list` / `pnpm nuke:gcp` can find them. Display name, |
| 351 | * combiner, conditions, channels, documentation, enabled, severity, |
| 352 | * strategy, and labels update in place. |
| 353 | * |
| 354 | * ### Creating a Policy |
| 355 | * **Example:** CPU threshold without a notification channel |
| 356 | * ```typescript |
| 357 | * const policy = yield* GCP.Monitoring.AlertPolicy("CpuHigh", { |
| 358 | * combiner: "OR", |
| 359 | * conditions: [ |
| 360 | * { |
| 361 | * displayName: "CPU > 90%", |
| 362 | * conditionThreshold: { |
| 363 | * filter: |
| 364 | * 'resource.type = "gce_instance" AND metric.type = "compute.googleapis.com/instance/cpu/utilization"', |
| 365 | * comparison: "COMPARISON_GT", |
| 366 | * thresholdValue: 0.9, |
| 367 | * duration: "60s", |
| 368 | * aggregations: [ |
| 369 | * { alignmentPeriod: "60s", perSeriesAligner: "ALIGN_MEAN" }, |
| 370 | * ], |
| 371 | * }, |
| 372 | * }, |
| 373 | * ], |
| 374 | * }); |
| 375 | * ``` |
| 376 | * |
| 377 | * **Example:** Policy that emails a channel |
| 378 | * ```typescript |
| 379 | * const channel = yield* GCP.Monitoring.NotificationChannel("Oncall", { |
| 380 | * type: "email", |
| 381 | * labels: { email_address: "oncall@example.com" }, |
| 382 | * }); |
| 383 | * const policy = yield* GCP.Monitoring.AlertPolicy("CpuHigh", { |
| 384 | * conditions: [ |
| 385 | * { |
| 386 | * displayName: "CPU > 90%", |
| 387 | * conditionThreshold: { |
| 388 | * filter: |
| 389 | * 'resource.type = "gce_instance" AND metric.type = "compute.googleapis.com/instance/cpu/utilization"', |
| 390 | * comparison: "COMPARISON_GT", |
| 391 | * thresholdValue: 0.9, |
| 392 | * duration: "60s", |
| 393 | * }, |
| 394 | * }, |
| 395 | * ], |
| 396 | * notificationChannels: [channel.name], |
| 397 | * }); |
| 398 | * ``` |
| 399 | * |
| 400 | * ### Updating a Policy |
| 401 | * **Example:** Lower the threshold and disable the policy |
| 402 | * ```typescript |
| 403 | * const policy = yield* GCP.Monitoring.AlertPolicy("CpuHigh", { |
| 404 | * combiner: "OR", |
| 405 | * enabled: false, |
| 406 | * conditions: [ |
| 407 | * { |
| 408 | * displayName: "CPU > 50%", |
| 409 | * conditionThreshold: { |
| 410 | * filter: |
| 411 | * 'resource.type = "gce_instance" AND metric.type = "compute.googleapis.com/instance/cpu/utilization"', |
| 412 | * comparison: "COMPARISON_GT", |
| 413 | * thresholdValue: 0.5, |
| 414 | * duration: "60s", |
| 415 | * }, |
| 416 | * }, |
| 417 | * ], |
| 418 | * }); |
| 419 | * ``` |
| 420 | * |
| 421 | * @resource |
| 422 | * @category Monitoring |
| 423 | */ |
| 424 | export const AlertPolicy = Resource<AlertPolicy>("GCP.Monitoring.AlertPolicy"); |
| 425 | |
| 426 | export class AlertPolicyNotResolved extends Data.TaggedError( |
| 427 | "GCP.Monitoring.AlertPolicyNotResolved", |
| 428 | )<{ |
| 429 | name: string; |
| 430 | }> {} |
| 431 | |
| 432 | const lastSegment = (value: string) => { |
| 433 | const trimmed = value.replace(/\/+$/, ""); |
| 434 | const parts = trimmed.split("/"); |
| 435 | return parts[parts.length - 1] || trimmed; |
| 436 | }; |
| 437 | |
| 438 | const parentOf = (project: string) => `projects/${project}`; |
| 439 | |
| 440 | const userFacingLabels = ( |
| 441 | labels: Record<string, string | undefined> | null | undefined, |
| 442 | ): Record<string, string> => stripInternalLabels(tagRecord(labels)); |
| 443 | |
| 444 | const jsonEqual = (left: unknown, right: unknown) => |
| 445 | JSON.stringify(canonicalize(left)) === JSON.stringify(canonicalize(right)); |
| 446 | |
| 447 | const canonicalize = (value: unknown): unknown => { |
| 448 | if (Array.isArray(value)) return value.map(canonicalize); |
| 449 | if (value !== null && typeof value === "object") { |
| 450 | return Object.fromEntries( |
| 451 | Object.entries(value as Record<string, unknown>) |
| 452 | .filter(([, item]) => item !== undefined) |
| 453 | .sort(([left], [right]) => left.localeCompare(right)) |
| 454 | .map(([key, item]) => [key, canonicalize(item)]), |
| 455 | ); |
| 456 | } |
| 457 | return value; |
| 458 | }; |
| 459 | |
| 460 | const sorted = (values: readonly string[] | undefined) => |
| 461 | [...(values ?? [])].sort(); |
| 462 | |
| 463 | const withoutName = (condition: AlertCondition | monitoring.Condition) => { |
| 464 | const { name: _name, ...rest } = condition; |
| 465 | return rest; |
| 466 | }; |
| 467 | |
| 468 | const toDisplayName = ( |
| 469 | id: string, |
| 470 | displayName: string | undefined, |
| 471 | existing?: string, |
| 472 | ) => |
| 473 | Effect.gen(function* () { |
| 474 | return ( |
| 475 | displayName ?? |
| 476 | existing ?? |
| 477 | (yield* createPhysicalName({ |
| 478 | id, |
| 479 | maxLength: MAX_DISPLAY_NAME_LENGTH, |
| 480 | lowercase: true, |
| 481 | })) |
| 482 | ); |
| 483 | }); |
| 484 | |
| 485 | const toLogMatch = ( |
| 486 | value: monitoring.LogMatch | undefined, |
| 487 | ): LogMatch | undefined => { |
| 488 | if (value === undefined) return undefined; |
| 489 | return { |
| 490 | filter: value.filter, |
| 491 | labelExtractors: compactStringMap(value.labelExtractors), |
| 492 | }; |
| 493 | }; |
| 494 | |
| 495 | const toPrometheusQueryLanguageCondition = ( |
| 496 | value: monitoring.PrometheusQueryLanguageCondition | undefined, |
| 497 | ): PrometheusQueryLanguageCondition | undefined => { |
| 498 | if (value === undefined) return undefined; |
| 499 | return { |
| 500 | query: value.query, |
| 501 | duration: value.duration, |
| 502 | evaluationInterval: value.evaluationInterval, |
| 503 | labels: compactStringMap(value.labels), |
| 504 | ruleGroup: value.ruleGroup, |
| 505 | alertRule: value.alertRule, |
| 506 | disableMetricValidation: value.disableMetricValidation, |
| 507 | }; |
| 508 | }; |
| 509 | |
| 510 | const toCondition = (condition: monitoring.Condition): AlertCondition => ({ |
| 511 | displayName: condition.displayName ?? "", |
| 512 | name: condition.name, |
| 513 | conditionThreshold: condition.conditionThreshold, |
| 514 | conditionAbsent: condition.conditionAbsent, |
| 515 | conditionMatchedLog: toLogMatch(condition.conditionMatchedLog), |
| 516 | conditionMonitoringQueryLanguage: condition.conditionMonitoringQueryLanguage, |
| 517 | conditionPrometheusQueryLanguage: toPrometheusQueryLanguageCondition( |
| 518 | condition.conditionPrometheusQueryLanguage, |
| 519 | ), |
| 520 | conditionSql: condition.conditionSql, |
| 521 | }); |
| 522 | |
| 523 | const toDocumentation = ( |
| 524 | documentation: monitoring.Documentation | undefined, |
| 525 | ): Documentation | undefined => { |
| 526 | if (documentation === undefined) return undefined; |
| 527 | return { |
| 528 | content: documentation.content, |
| 529 | mimeType: documentation.mimeType, |
| 530 | subject: documentation.subject, |
| 531 | links: documentation.links, |
| 532 | }; |
| 533 | }; |
| 534 | |
| 535 | const toAlertStrategy = ( |
| 536 | strategy: monitoring.AlertStrategy | undefined, |
| 537 | ): AlertStrategy | undefined => { |
| 538 | if (strategy === undefined) return undefined; |
| 539 | return { |
| 540 | autoClose: strategy.autoClose, |
| 541 | notificationRateLimit: strategy.notificationRateLimit, |
| 542 | notificationPrompts: strategy.notificationPrompts, |
| 543 | notificationChannelStrategy: strategy.notificationChannelStrategy, |
| 544 | }; |
| 545 | }; |
| 546 | |
| 547 | const toAttrs = (policy: monitoring.AlertPolicy, project: string) => { |
| 548 | const name = policy.name ?? ""; |
| 549 | return { |
| 550 | name, |
| 551 | alertPolicyId: lastSegment(name), |
| 552 | project, |
| 553 | displayName: policy.displayName, |
| 554 | combiner: policy.combiner, |
| 555 | conditions: (policy.conditions ?? []).map(toCondition), |
| 556 | notificationChannels: policy.notificationChannels ?? [], |
| 557 | documentation: toDocumentation(policy.documentation), |
| 558 | enabled: policy.enabled !== false, |
| 559 | severity: policy.severity, |
| 560 | alertStrategy: toAlertStrategy(policy.alertStrategy), |
| 561 | labels: userFacingLabels(policy.userLabels), |
| 562 | }; |
| 563 | }; |
| 564 | |
| 565 | const toApiCondition = (condition: AlertCondition): monitoring.Condition => ({ |
| 566 | displayName: condition.displayName, |
| 567 | name: condition.name, |
| 568 | conditionThreshold: condition.conditionThreshold, |
| 569 | conditionAbsent: condition.conditionAbsent, |
| 570 | conditionMatchedLog: condition.conditionMatchedLog, |
| 571 | conditionMonitoringQueryLanguage: condition.conditionMonitoringQueryLanguage, |
| 572 | conditionPrometheusQueryLanguage: condition.conditionPrometheusQueryLanguage, |
| 573 | conditionSql: condition.conditionSql as monitoring.SqlCondition | undefined, |
| 574 | }); |
| 575 | |
| 576 | const withConditionNames = ( |
| 577 | desired: readonly AlertCondition[], |
| 578 | observed: readonly monitoring.Condition[] | undefined, |
| 579 | ): monitoring.Condition[] => { |
| 580 | const unused = [...(observed ?? [])]; |
| 581 | return desired.map((condition, index) => { |
| 582 | if (condition.name !== undefined) return toApiCondition(condition); |
| 583 | const byDisplay = unused.findIndex( |
| 584 | (item) => item.displayName === condition.displayName, |
| 585 | ); |
| 586 | const matchIndex = |
| 587 | byDisplay >= 0 ? byDisplay : index < unused.length ? index : -1; |
| 588 | const match = matchIndex >= 0 ? unused.splice(matchIndex, 1)[0] : undefined; |
| 589 | return { ...toApiCondition(condition), name: match?.name }; |
| 590 | }); |
| 591 | }; |
| 592 | |
| 593 | const getByName = (name: string) => |
| 594 | monitoring |
| 595 | .getProjectsAlertPolicies({ name }) |
| 596 | .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); |
| 597 | |
| 598 | const listOwned = (project: string) => |
| 599 | monitoring.listProjectsAlertPolicies |
| 600 | .pages({ |
| 601 | name: parentOf(project), |
| 602 | pageSize: 1000, |
| 603 | }) |
| 604 | .pipe( |
| 605 | Stream.flatMap((page) => Stream.fromIterable(page.alertPolicies ?? [])), |
| 606 | Stream.filter((policy) => |
| 607 | Object.keys(policy.userLabels ?? {}).some((key) => |
| 608 | key.startsWith("alchemy-"), |
| 609 | ), |
| 610 | ), |
| 611 | Stream.map((policy) => toAttrs(policy, project)), |
| 612 | Stream.runCollect, |
| 613 | Effect.map((chunk) => Array.from(chunk)), |
| 614 | Effect.catchTag("NotFound", () => Effect.succeed([])), |
| 615 | ); |
| 616 | |
| 617 | const findOwned = (project: string, id: string) => |
| 618 | Effect.gen(function* () { |
| 619 | const owned = yield* monitoring.listProjectsAlertPolicies |
| 620 | .pages({ |
| 621 | name: parentOf(project), |
| 622 | pageSize: 1000, |
| 623 | }) |
| 624 | .pipe( |
| 625 | Stream.flatMap((page) => Stream.fromIterable(page.alertPolicies ?? [])), |
| 626 | Stream.runCollect, |
| 627 | Effect.map((chunk) => Array.from(chunk)), |
| 628 | Effect.catchTag("NotFound", () => Effect.succeed([])), |
| 629 | ); |
| 630 | for (const policy of owned) { |
| 631 | if (yield* hasAlchemyLabels(id, tagRecord(policy.userLabels))) { |
| 632 | return policy; |
| 633 | } |
| 634 | } |
| 635 | return undefined; |
| 636 | }); |
| 637 | |
| 638 | const observe = (project: string, id: string, name: string | undefined) => |
| 639 | Effect.gen(function* () { |
| 640 | if (name !== undefined) { |
| 641 | const existing = yield* getByName(name); |
| 642 | if (existing !== undefined) return existing; |
| 643 | } |
| 644 | return yield* findOwned(project, id); |
| 645 | }); |
| 646 | |
| 647 | export const AlertPolicyProvider = () => |
| 648 | Provider.succeed(AlertPolicy, { |
| 649 | stables: ["name", "alertPolicyId", "project"], |
| 650 | |
| 651 | read: Effect.fn(function* ({ id, output }) { |
| 652 | const env = yield* GcpEnvironment.current; |
| 653 | const existing = yield* observe(env.project, id, output?.name); |
| 654 | if (existing === undefined) return undefined; |
| 655 | const attrs = toAttrs(existing, env.project); |
| 656 | return (yield* hasAlchemyLabels(id, tagRecord(existing.userLabels))) |
| 657 | ? attrs |
| 658 | : Unowned(attrs); |
| 659 | }), |
| 660 | |
| 661 | list: () => |
| 662 | Effect.gen(function* () { |
| 663 | const env = yield* GcpEnvironment.current; |
| 664 | return yield* listOwned(env.project); |
| 665 | }), |
| 666 | |
| 667 | reconcile: Effect.fn(function* ({ id, news, output }) { |
| 668 | const env = yield* GcpEnvironment.current; |
| 669 | const displayName = yield* toDisplayName( |
| 670 | id, |
| 671 | news.displayName, |
| 672 | output?.displayName, |
| 673 | ); |
| 674 | const desiredLabels = { |
| 675 | ...toLabels(news.labels), |
| 676 | ...(yield* createInternalLabels(id)), |
| 677 | }; |
| 678 | const combiner = news.combiner ?? DEFAULT_COMBINER; |
| 679 | const desiredEnabled = news.enabled !== false; |
| 680 | const desiredChannels = news.notificationChannels ?? []; |
| 681 | |
| 682 | let current = yield* observe(env.project, id, output?.name); |
| 683 | |
| 684 | const conditions = withConditionNames( |
| 685 | news.conditions, |
| 686 | current?.conditions, |
| 687 | ); |
| 688 | const body: monitoring.AlertPolicy = { |
| 689 | displayName, |
| 690 | combiner, |
| 691 | enabled: desiredEnabled, |
| 692 | userLabels: desiredLabels, |
| 693 | conditions, |
| 694 | notificationChannels: desiredChannels, |
| 695 | documentation: news.documentation, |
| 696 | alertStrategy: news.alertStrategy as |
| 697 | | monitoring.AlertStrategy |
| 698 | | undefined, |
| 699 | severity: news.severity, |
| 700 | }; |
| 701 | |
| 702 | if (current === undefined) { |
| 703 | const created = yield* monitoring |
| 704 | .createProjectsAlertPolicies({ |
| 705 | name: parentOf(env.project), |
| 706 | body: { |
| 707 | ...body, |
| 708 | conditions: news.conditions.map((condition) => |
| 709 | toApiCondition({ ...condition, name: undefined }), |
| 710 | ), |
| 711 | }, |
| 712 | }) |
| 713 | .pipe(Effect.catchTag("Conflict", () => findOwned(env.project, id))); |
| 714 | current = created ?? undefined; |
| 715 | } |
| 716 | |
| 717 | if (current === undefined) { |
| 718 | return yield* new AlertPolicyNotResolved({ |
| 719 | name: output?.name ?? displayName, |
| 720 | }); |
| 721 | } |
| 722 | |
| 723 | const name = current.name ?? output?.name ?? ""; |
| 724 | const observedLabels = tagRecord(current.userLabels); |
| 725 | const { upsert, removed } = diffLabels(observedLabels, desiredLabels); |
| 726 | const labelsChanged = upsert.length > 0 || removed.length > 0; |
| 727 | const displayNameChanged = (current.displayName ?? "") !== displayName; |
| 728 | const combinerChanged = |
| 729 | (current.combiner ?? DEFAULT_COMBINER) !== combiner; |
| 730 | const enabledChanged = (current.enabled !== false) !== desiredEnabled; |
| 731 | const channelsChanged = !jsonEqual( |
| 732 | sorted(current.notificationChannels), |
| 733 | sorted(desiredChannels), |
| 734 | ); |
| 735 | const conditionsChanged = !jsonEqual( |
| 736 | (current.conditions ?? []).map(withoutName), |
| 737 | conditions.map(withoutName), |
| 738 | ); |
| 739 | const documentationChanged = |
| 740 | news.documentation !== undefined && |
| 741 | !jsonEqual( |
| 742 | toDocumentation(current.documentation) ?? null, |
| 743 | news.documentation, |
| 744 | ); |
| 745 | const strategyChanged = |
| 746 | news.alertStrategy !== undefined && |
| 747 | !jsonEqual( |
| 748 | toAlertStrategy(current.alertStrategy) ?? null, |
| 749 | news.alertStrategy, |
| 750 | ); |
| 751 | const severityChanged = |
| 752 | news.severity !== undefined && |
| 753 | (current.severity ?? "") !== news.severity; |
| 754 | |
| 755 | const updateMask = [ |
| 756 | displayNameChanged ? "display_name" : undefined, |
| 757 | combinerChanged ? "combiner" : undefined, |
| 758 | enabledChanged ? "enabled" : undefined, |
| 759 | labelsChanged ? "user_labels" : undefined, |
| 760 | channelsChanged ? "notification_channels" : undefined, |
| 761 | conditionsChanged ? "conditions" : undefined, |
| 762 | documentationChanged ? "documentation" : undefined, |
| 763 | strategyChanged ? "alert_strategy" : undefined, |
| 764 | severityChanged ? "severity" : undefined, |
| 765 | ].filter((field): field is string => field !== undefined); |
| 766 | |
| 767 | if (updateMask.length > 0) { |
| 768 | current = yield* monitoring.patchProjectsAlertPolicies({ |
| 769 | name, |
| 770 | updateMask: updateMask.join(","), |
| 771 | body: { |
| 772 | ...body, |
| 773 | name, |
| 774 | conditions: withConditionNames(news.conditions, current.conditions), |
| 775 | }, |
| 776 | }); |
| 777 | } |
| 778 | |
| 779 | return toAttrs(current, env.project); |
| 780 | }), |
| 781 | |
| 782 | delete: Effect.fn(function* ({ output }) { |
| 783 | yield* monitoring |
| 784 | .deleteProjectsAlertPolicies({ name: output.name }) |
| 785 | .pipe(Effect.catchTag("NotFound", () => Effect.void)); |
| 786 | }), |
| 787 | }); |