1import * as monitoring from "@distilled.cloud/gcp/monitoring_v3";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { tagRecord } from "../../Tags.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import {
12 createInternalLabels,
13 diffLabels,
14 hasAlchemyLabels,
15 stripInternalLabels,
16 toLabels,
17} from "../Labels.ts";
18import type { Providers } from "../Providers.ts";
19import { compactStringMap } from "./ownership.ts";
20
21const MAX_DISPLAY_NAME_LENGTH = 512;
22const DEFAULT_COMBINER = "OR";
23
24export type AlertPolicyCombiner =
25 | "AND"
26 | "OR"
27 | "AND_WITH_MATCHING_RESOURCE"
28 | (string & {});
29
30export type AlertPolicySeverity =
31 | "CRITICAL"
32 | "ERROR"
33 | "WARNING"
34 | (string & {});
35
36export 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
56export 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
63export type ForecastOptions = {
64 /**
65 * How far ahead to forecast a threshold violation (1h–60h).
66 */
67 forecastHorizon?: string;
68};
69
70export 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
117export 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
136export 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
147export 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
162export 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
182export 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
205export 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
230export type DocumentationLink = {
231 /** Short link title (max 63 characters). */
232 displayName?: string;
233 /** URL. May include alert template variables. */
234 url?: string;
235};
236
237export 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
251export 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
268export 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
312export 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 */
424export const AlertPolicy = Resource<AlertPolicy>("GCP.Monitoring.AlertPolicy");
425
426export class AlertPolicyNotResolved extends Data.TaggedError(
427 "GCP.Monitoring.AlertPolicyNotResolved",
428)<{
429 name: string;
430}> {}
431
432const lastSegment = (value: string) => {
433 const trimmed = value.replace(/\/+$/, "");
434 const parts = trimmed.split("/");
435 return parts[parts.length - 1] || trimmed;
436};
437
438const parentOf = (project: string) => `projects/${project}`;
439
440const userFacingLabels = (
441 labels: Record<string, string | undefined> | null | undefined,
442): Record<string, string> => stripInternalLabels(tagRecord(labels));
443
444const jsonEqual = (left: unknown, right: unknown) =>
445 JSON.stringify(canonicalize(left)) === JSON.stringify(canonicalize(right));
446
447const 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
460const sorted = (values: readonly string[] | undefined) =>
461 [...(values ?? [])].sort();
462
463const withoutName = (condition: AlertCondition | monitoring.Condition) => {
464 const { name: _name, ...rest } = condition;
465 return rest;
466};
467
468const 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
485const 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
495const 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
510const 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
523const 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
535const 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
547const 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
565const 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
576const 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
593const getByName = (name: string) =>
594 monitoring
595 .getProjectsAlertPolicies({ name })
596 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
597
598const 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
617const 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
638const 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
647export 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 });