Skip to content

Commit c403faf

Browse files
fix(cloudwatch): use PutAlarmMuteRule for mute/unmute with duration window (#4621)
* fix(cloudwatch): use PutAlarmMuteRule for mute/unmute with duration window * fix(cloudwatch): drop AWS StartDate to avoid race with at() trigger
1 parent 0dc1611 commit c403faf

8 files changed

Lines changed: 274 additions & 57 deletions

File tree

apps/sim/app/api/tools/cloudwatch/mute-alarm/route.ts

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { CloudWatchClient, DisableAlarmActionsCommand } from '@aws-sdk/client-cloudwatch'
1+
import { CloudWatchClient, PutAlarmMuteRuleCommand } from '@aws-sdk/client-cloudwatch'
22
import { createLogger } from '@sim/logger'
33
import { toError } from '@sim/utils/errors'
44
import { type NextRequest, NextResponse } from 'next/server'
@@ -9,6 +9,26 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
99

1010
const logger = createLogger('CloudWatchMuteAlarm')
1111

12+
function toAtExpression(date: Date): string {
13+
const yyyy = date.getUTCFullYear()
14+
const mm = String(date.getUTCMonth() + 1).padStart(2, '0')
15+
const dd = String(date.getUTCDate()).padStart(2, '0')
16+
const hh = String(date.getUTCHours()).padStart(2, '0')
17+
const min = String(date.getUTCMinutes()).padStart(2, '0')
18+
return `at(${yyyy}-${mm}-${dd}T${hh}:${min})`
19+
}
20+
21+
function toIsoDuration(value: number, unit: 'minutes' | 'hours' | 'days'): string {
22+
switch (unit) {
23+
case 'minutes':
24+
return `PT${value}M`
25+
case 'hours':
26+
return `PT${value}H`
27+
case 'days':
28+
return `P${value}D`
29+
}
30+
}
31+
1232
export const POST = withRouteHandler(async (request: NextRequest) => {
1333
try {
1434
const auth = await checkInternalAuth(request)
@@ -23,7 +43,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
2343
if (!parsed.success) return parsed.response
2444
const validatedData = parsed.data.body
2545

26-
logger.info(`Muting ${validatedData.alarmNames.length} CloudWatch alarm(s)`)
46+
const startDate =
47+
validatedData.startDate !== undefined ? new Date(validatedData.startDate * 1000) : new Date()
48+
const expression = toAtExpression(startDate)
49+
const duration = toIsoDuration(validatedData.durationValue, validatedData.durationUnit)
50+
51+
logger.info(
52+
`Creating CloudWatch alarm mute rule "${validatedData.muteRuleName}" for ${validatedData.alarmNames.length} alarm(s) (${expression}, duration ${duration})`
53+
)
2754

2855
const client = new CloudWatchClient({
2956
region: validatedData.region,
@@ -34,19 +61,30 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
3461
})
3562

3663
try {
37-
const command = new DisableAlarmActionsCommand({
38-
AlarmNames: validatedData.alarmNames,
64+
const command = new PutAlarmMuteRuleCommand({
65+
Name: validatedData.muteRuleName,
66+
...(validatedData.description && { Description: validatedData.description }),
67+
Rule: {
68+
Schedule: {
69+
Expression: expression,
70+
Duration: duration,
71+
},
72+
},
73+
MuteTargets: { AlarmNames: validatedData.alarmNames },
3974
})
4075

4176
await client.send(command)
4277

43-
logger.info(`Successfully muted ${validatedData.alarmNames.length} alarm(s)`)
78+
logger.info(`Successfully created mute rule "${validatedData.muteRuleName}"`)
4479

4580
return NextResponse.json({
4681
success: true,
4782
output: {
4883
success: true,
84+
muteRuleName: validatedData.muteRuleName,
4985
alarmNames: validatedData.alarmNames,
86+
expression,
87+
duration,
5088
},
5189
})
5290
} finally {
@@ -55,7 +93,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
5593
} catch (error) {
5694
logger.error('MuteAlarm failed', { error: toError(error).message })
5795
return NextResponse.json(
58-
{ error: `Failed to mute CloudWatch alarm: ${toError(error).message}` },
96+
{ error: `Failed to create CloudWatch alarm mute rule: ${toError(error).message}` },
5997
{ status: 500 }
6098
)
6199
}

apps/sim/app/api/tools/cloudwatch/unmute-alarm/route.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { CloudWatchClient, EnableAlarmActionsCommand } from '@aws-sdk/client-cloudwatch'
1+
import { CloudWatchClient, DeleteAlarmMuteRuleCommand } from '@aws-sdk/client-cloudwatch'
22
import { createLogger } from '@sim/logger'
33
import { toError } from '@sim/utils/errors'
44
import { type NextRequest, NextResponse } from 'next/server'
@@ -23,7 +23,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
2323
if (!parsed.success) return parsed.response
2424
const validatedData = parsed.data.body
2525

26-
logger.info(`Unmuting ${validatedData.alarmNames.length} CloudWatch alarm(s)`)
26+
logger.info(`Deleting CloudWatch alarm mute rule "${validatedData.muteRuleName}"`)
2727

2828
const client = new CloudWatchClient({
2929
region: validatedData.region,
@@ -34,19 +34,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
3434
})
3535

3636
try {
37-
const command = new EnableAlarmActionsCommand({
38-
AlarmNames: validatedData.alarmNames,
37+
const command = new DeleteAlarmMuteRuleCommand({
38+
AlarmMuteRuleName: validatedData.muteRuleName,
3939
})
4040

4141
await client.send(command)
4242

43-
logger.info(`Successfully unmuted ${validatedData.alarmNames.length} alarm(s)`)
43+
logger.info(`Successfully deleted mute rule "${validatedData.muteRuleName}"`)
4444

4545
return NextResponse.json({
4646
success: true,
4747
output: {
4848
success: true,
49-
alarmNames: validatedData.alarmNames,
49+
muteRuleName: validatedData.muteRuleName,
5050
},
5151
})
5252
} finally {
@@ -55,7 +55,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
5555
} catch (error) {
5656
logger.error('UnmuteAlarm failed', { error: toError(error).message })
5757
return NextResponse.json(
58-
{ error: `Failed to unmute CloudWatch alarm: ${toError(error).message}` },
58+
{ error: `Failed to delete CloudWatch alarm mute rule: ${toError(error).message}` },
5959
{ status: 500 }
6060
)
6161
}

apps/sim/blocks/blocks/cloudwatch.ts

Lines changed: 108 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -366,13 +366,59 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
366366
value: () => '',
367367
condition: { field: 'operation', value: 'describe_alarms' },
368368
},
369+
{
370+
id: 'muteRuleName',
371+
title: 'Mute Rule Name',
372+
type: 'short-input',
373+
placeholder: 'my-mute-rule',
374+
condition: { field: 'operation', value: ['mute_alarm', 'unmute_alarm'] },
375+
required: { field: 'operation', value: ['mute_alarm', 'unmute_alarm'] },
376+
},
369377
{
370378
id: 'alarmNames',
371379
title: 'Alarm Names',
372380
type: 'short-input',
373381
placeholder: 'my-alarm-1, my-alarm-2',
374-
condition: { field: 'operation', value: ['mute_alarm', 'unmute_alarm'] },
375-
required: { field: 'operation', value: ['mute_alarm', 'unmute_alarm'] },
382+
condition: { field: 'operation', value: 'mute_alarm' },
383+
required: { field: 'operation', value: 'mute_alarm' },
384+
},
385+
{
386+
id: 'durationValue',
387+
title: 'Duration',
388+
type: 'short-input',
389+
placeholder: '1',
390+
condition: { field: 'operation', value: 'mute_alarm' },
391+
required: { field: 'operation', value: 'mute_alarm' },
392+
},
393+
{
394+
id: 'durationUnit',
395+
title: 'Duration Unit',
396+
type: 'dropdown',
397+
options: [
398+
{ label: 'Minutes', id: 'minutes' },
399+
{ label: 'Hours', id: 'hours' },
400+
{ label: 'Days', id: 'days' },
401+
],
402+
value: () => 'hours',
403+
condition: { field: 'operation', value: 'mute_alarm' },
404+
required: { field: 'operation', value: 'mute_alarm' },
405+
},
406+
{
407+
id: 'muteDescription',
408+
title: 'Description',
409+
type: 'short-input',
410+
placeholder: 'Why these alarms are being muted',
411+
condition: { field: 'operation', value: 'mute_alarm' },
412+
mode: 'advanced',
413+
},
414+
{
415+
id: 'muteStartDate',
416+
title: 'Start Date',
417+
type: 'short-input',
418+
placeholder: 'e.g., 1711900800',
419+
condition: { field: 'operation', value: 'mute_alarm' },
420+
mode: 'advanced',
421+
description: 'Unix epoch seconds. Defaults to now (mute starts immediately).',
376422
},
377423
{
378424
id: 'limit',
@@ -633,8 +679,7 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
633679
...(parsedLimit !== undefined && { limit: parsedLimit }),
634680
}
635681

636-
case 'mute_alarm':
637-
case 'unmute_alarm': {
682+
case 'mute_alarm': {
638683
const alarmNames = rest.alarmNames
639684
if (!alarmNames) {
640685
throw new Error('Alarm names are required')
@@ -652,11 +697,45 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
652697
throw new Error('At least one alarm name is required')
653698
}
654699

700+
const durationValueRaw = rest.durationValue
701+
const parsedDurationValue =
702+
typeof durationValueRaw === 'number'
703+
? durationValueRaw
704+
: Number.parseInt(String(durationValueRaw ?? ''), 10)
705+
if (!Number.isFinite(parsedDurationValue) || parsedDurationValue < 1) {
706+
throw new Error('Duration must be a positive integer')
707+
}
708+
709+
const startDateRaw = rest.muteStartDate
710+
const parsedStartDate =
711+
startDateRaw === undefined || startDateRaw === ''
712+
? undefined
713+
: typeof startDateRaw === 'number'
714+
? startDateRaw
715+
: Number.parseInt(String(startDateRaw), 10)
716+
if (parsedStartDate !== undefined && !Number.isFinite(parsedStartDate)) {
717+
throw new Error('Start date must be a Unix epoch in seconds')
718+
}
719+
655720
return {
656721
awsRegion,
657722
awsAccessKeyId,
658723
awsSecretAccessKey,
724+
muteRuleName: rest.muteRuleName,
659725
alarmNames: names,
726+
durationValue: parsedDurationValue,
727+
durationUnit: rest.durationUnit,
728+
...(rest.muteDescription && { description: rest.muteDescription }),
729+
...(parsedStartDate !== undefined && { startDate: parsedStartDate }),
730+
}
731+
}
732+
733+
case 'unmute_alarm': {
734+
return {
735+
awsRegion,
736+
awsAccessKeyId,
737+
awsSecretAccessKey,
738+
muteRuleName: rest.muteRuleName,
660739
}
661740
}
662741

@@ -700,7 +779,18 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
700779
description: 'Alarm state filter (OK, ALARM, INSUFFICIENT_DATA)',
701780
},
702781
alarmType: { type: 'string', description: 'Alarm type filter (MetricAlarm, CompositeAlarm)' },
703-
alarmNames: { type: 'string', description: 'Comma-separated alarm names to mute or unmute' },
782+
muteRuleName: { type: 'string', description: 'Unique name for the alarm mute rule' },
783+
alarmNames: { type: 'string', description: 'Comma-separated alarm names to mute' },
784+
durationValue: { type: 'number', description: 'Length of the mute window' },
785+
durationUnit: {
786+
type: 'string',
787+
description: 'Unit for durationValue: minutes, hours, or days',
788+
},
789+
muteDescription: { type: 'string', description: 'Description of the mute rule' },
790+
muteStartDate: {
791+
type: 'number',
792+
description: 'When the mute begins (Unix epoch seconds). Defaults to now.',
793+
},
704794
limit: { type: 'number', description: 'Maximum number of results' },
705795
},
706796
outputs: {
@@ -746,7 +836,19 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
746836
},
747837
alarmNames: {
748838
type: 'array',
749-
description: 'Names of the alarms that were muted or unmuted',
839+
description: 'Names of the alarms targeted by the mute rule',
840+
},
841+
muteRuleName: {
842+
type: 'string',
843+
description: 'Name of the alarm mute rule that was created or deleted',
844+
},
845+
expression: {
846+
type: 'string',
847+
description: 'Schedule expression used by the mute rule',
848+
},
849+
duration: {
850+
type: 'string',
851+
description: 'ISO 8601 duration of the mute window',
750852
},
751853
success: {
752854
type: 'boolean',

apps/sim/lib/api/contracts/tools/aws/cloudwatch-mute-alarm.ts

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,26 +7,58 @@ import type {
77
import { defineRouteContract } from '@/lib/api/contracts/types'
88
import { validateAwsRegion } from '@/lib/core/security/input-validation'
99

10-
const MuteAlarmSchema = z.object({
11-
region: z
12-
.string()
13-
.min(1, 'AWS region is required')
14-
.refine((v) => validateAwsRegion(v).isValid, {
15-
message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)',
16-
}),
17-
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
18-
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
19-
alarmNames: z
20-
.array(z.string().min(1, 'Alarm name cannot be empty'))
21-
.min(1, 'At least one alarm name is required')
22-
.max(100, 'At most 100 alarm names are allowed per request'),
23-
})
10+
const MAX_MUTE_MINUTES = 15 * 24 * 60
11+
12+
const MuteAlarmSchema = z
13+
.object({
14+
region: z
15+
.string()
16+
.min(1, 'AWS region is required')
17+
.refine((v) => validateAwsRegion(v).isValid, {
18+
message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)',
19+
}),
20+
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
21+
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
22+
muteRuleName: z
23+
.string()
24+
.min(1, 'muteRuleName cannot be empty')
25+
.max(255, 'muteRuleName must be at most 255 characters'),
26+
alarmNames: z
27+
.array(z.string().min(1, 'Alarm name cannot be empty').max(255))
28+
.min(1, 'At least one alarm name is required')
29+
.max(100, 'At most 100 alarm names are allowed per mute rule'),
30+
durationValue: z
31+
.number()
32+
.int('durationValue must be an integer')
33+
.min(1, 'durationValue must be at least 1'),
34+
durationUnit: z.enum(['minutes', 'hours', 'days']),
35+
description: z.string().max(1024).optional(),
36+
startDate: z
37+
.number()
38+
.int('startDate must be an integer')
39+
.min(0, 'startDate must be a non-negative Unix epoch in seconds')
40+
.optional(),
41+
})
42+
.superRefine((data, ctx) => {
43+
const minutesPerUnit = { minutes: 1, hours: 60, days: 1440 } as const
44+
const totalMinutes = data.durationValue * minutesPerUnit[data.durationUnit]
45+
if (totalMinutes > MAX_MUTE_MINUTES) {
46+
ctx.addIssue({
47+
code: 'custom',
48+
message: 'duration must be at most 15 days (CloudWatch mute rule limit)',
49+
path: ['durationValue'],
50+
})
51+
}
52+
})
2453

2554
const MuteAlarmResponseSchema = z.object({
2655
success: z.literal(true),
2756
output: z.object({
2857
success: z.literal(true),
58+
muteRuleName: z.string(),
2959
alarmNames: z.array(z.string()),
60+
expression: z.string(),
61+
duration: z.string(),
3062
}),
3163
})
3264

apps/sim/lib/api/contracts/tools/aws/cloudwatch-unmute-alarm.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,17 @@ const UnmuteAlarmSchema = z.object({
1616
}),
1717
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
1818
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
19-
alarmNames: z
20-
.array(z.string().min(1, 'Alarm name cannot be empty'))
21-
.min(1, 'At least one alarm name is required')
22-
.max(100, 'At most 100 alarm names are allowed per request'),
19+
muteRuleName: z
20+
.string()
21+
.min(1, 'muteRuleName cannot be empty')
22+
.max(255, 'muteRuleName must be at most 255 characters'),
2323
})
2424

2525
const UnmuteAlarmResponseSchema = z.object({
2626
success: z.literal(true),
2727
output: z.object({
2828
success: z.literal(true),
29-
alarmNames: z.array(z.string()),
29+
muteRuleName: z.string(),
3030
}),
3131
})
3232

0 commit comments

Comments
 (0)