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
This commit is contained in:
Theodore Li
2026-05-15 19:39:46 -04:00
committed by GitHub
parent 0dc1611f39
commit c403fafd11
8 changed files with 274 additions and 57 deletions
@@ -1,4 +1,4 @@
import { CloudWatchClient, DisableAlarmActionsCommand } from '@aws-sdk/client-cloudwatch'
import { CloudWatchClient, PutAlarmMuteRuleCommand } from '@aws-sdk/client-cloudwatch'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
@@ -9,6 +9,26 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('CloudWatchMuteAlarm')
function toAtExpression(date: Date): string {
const yyyy = date.getUTCFullYear()
const mm = String(date.getUTCMonth() + 1).padStart(2, '0')
const dd = String(date.getUTCDate()).padStart(2, '0')
const hh = String(date.getUTCHours()).padStart(2, '0')
const min = String(date.getUTCMinutes()).padStart(2, '0')
return `at(${yyyy}-${mm}-${dd}T${hh}:${min})`
}
function toIsoDuration(value: number, unit: 'minutes' | 'hours' | 'days'): string {
switch (unit) {
case 'minutes':
return `PT${value}M`
case 'hours':
return `PT${value}H`
case 'days':
return `P${value}D`
}
}
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkInternalAuth(request)
@@ -23,7 +43,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (!parsed.success) return parsed.response
const validatedData = parsed.data.body
logger.info(`Muting ${validatedData.alarmNames.length} CloudWatch alarm(s)`)
const startDate =
validatedData.startDate !== undefined ? new Date(validatedData.startDate * 1000) : new Date()
const expression = toAtExpression(startDate)
const duration = toIsoDuration(validatedData.durationValue, validatedData.durationUnit)
logger.info(
`Creating CloudWatch alarm mute rule "${validatedData.muteRuleName}" for ${validatedData.alarmNames.length} alarm(s) (${expression}, duration ${duration})`
)
const client = new CloudWatchClient({
region: validatedData.region,
@@ -34,19 +61,30 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
})
try {
const command = new DisableAlarmActionsCommand({
AlarmNames: validatedData.alarmNames,
const command = new PutAlarmMuteRuleCommand({
Name: validatedData.muteRuleName,
...(validatedData.description && { Description: validatedData.description }),
Rule: {
Schedule: {
Expression: expression,
Duration: duration,
},
},
MuteTargets: { AlarmNames: validatedData.alarmNames },
})
await client.send(command)
logger.info(`Successfully muted ${validatedData.alarmNames.length} alarm(s)`)
logger.info(`Successfully created mute rule "${validatedData.muteRuleName}"`)
return NextResponse.json({
success: true,
output: {
success: true,
muteRuleName: validatedData.muteRuleName,
alarmNames: validatedData.alarmNames,
expression,
duration,
},
})
} finally {
@@ -55,7 +93,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
} catch (error) {
logger.error('MuteAlarm failed', { error: toError(error).message })
return NextResponse.json(
{ error: `Failed to mute CloudWatch alarm: ${toError(error).message}` },
{ error: `Failed to create CloudWatch alarm mute rule: ${toError(error).message}` },
{ status: 500 }
)
}
@@ -1,4 +1,4 @@
import { CloudWatchClient, EnableAlarmActionsCommand } from '@aws-sdk/client-cloudwatch'
import { CloudWatchClient, DeleteAlarmMuteRuleCommand } from '@aws-sdk/client-cloudwatch'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
@@ -23,7 +23,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (!parsed.success) return parsed.response
const validatedData = parsed.data.body
logger.info(`Unmuting ${validatedData.alarmNames.length} CloudWatch alarm(s)`)
logger.info(`Deleting CloudWatch alarm mute rule "${validatedData.muteRuleName}"`)
const client = new CloudWatchClient({
region: validatedData.region,
@@ -34,19 +34,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
})
try {
const command = new EnableAlarmActionsCommand({
AlarmNames: validatedData.alarmNames,
const command = new DeleteAlarmMuteRuleCommand({
AlarmMuteRuleName: validatedData.muteRuleName,
})
await client.send(command)
logger.info(`Successfully unmuted ${validatedData.alarmNames.length} alarm(s)`)
logger.info(`Successfully deleted mute rule "${validatedData.muteRuleName}"`)
return NextResponse.json({
success: true,
output: {
success: true,
alarmNames: validatedData.alarmNames,
muteRuleName: validatedData.muteRuleName,
},
})
} finally {
@@ -55,7 +55,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
} catch (error) {
logger.error('UnmuteAlarm failed', { error: toError(error).message })
return NextResponse.json(
{ error: `Failed to unmute CloudWatch alarm: ${toError(error).message}` },
{ error: `Failed to delete CloudWatch alarm mute rule: ${toError(error).message}` },
{ status: 500 }
)
}
+108 -6
View File
@@ -366,13 +366,59 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
value: () => '',
condition: { field: 'operation', value: 'describe_alarms' },
},
{
id: 'muteRuleName',
title: 'Mute Rule Name',
type: 'short-input',
placeholder: 'my-mute-rule',
condition: { field: 'operation', value: ['mute_alarm', 'unmute_alarm'] },
required: { field: 'operation', value: ['mute_alarm', 'unmute_alarm'] },
},
{
id: 'alarmNames',
title: 'Alarm Names',
type: 'short-input',
placeholder: 'my-alarm-1, my-alarm-2',
condition: { field: 'operation', value: ['mute_alarm', 'unmute_alarm'] },
required: { field: 'operation', value: ['mute_alarm', 'unmute_alarm'] },
condition: { field: 'operation', value: 'mute_alarm' },
required: { field: 'operation', value: 'mute_alarm' },
},
{
id: 'durationValue',
title: 'Duration',
type: 'short-input',
placeholder: '1',
condition: { field: 'operation', value: 'mute_alarm' },
required: { field: 'operation', value: 'mute_alarm' },
},
{
id: 'durationUnit',
title: 'Duration Unit',
type: 'dropdown',
options: [
{ label: 'Minutes', id: 'minutes' },
{ label: 'Hours', id: 'hours' },
{ label: 'Days', id: 'days' },
],
value: () => 'hours',
condition: { field: 'operation', value: 'mute_alarm' },
required: { field: 'operation', value: 'mute_alarm' },
},
{
id: 'muteDescription',
title: 'Description',
type: 'short-input',
placeholder: 'Why these alarms are being muted',
condition: { field: 'operation', value: 'mute_alarm' },
mode: 'advanced',
},
{
id: 'muteStartDate',
title: 'Start Date',
type: 'short-input',
placeholder: 'e.g., 1711900800',
condition: { field: 'operation', value: 'mute_alarm' },
mode: 'advanced',
description: 'Unix epoch seconds. Defaults to now (mute starts immediately).',
},
{
id: 'limit',
@@ -633,8 +679,7 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
...(parsedLimit !== undefined && { limit: parsedLimit }),
}
case 'mute_alarm':
case 'unmute_alarm': {
case 'mute_alarm': {
const alarmNames = rest.alarmNames
if (!alarmNames) {
throw new Error('Alarm names are required')
@@ -652,11 +697,45 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
throw new Error('At least one alarm name is required')
}
const durationValueRaw = rest.durationValue
const parsedDurationValue =
typeof durationValueRaw === 'number'
? durationValueRaw
: Number.parseInt(String(durationValueRaw ?? ''), 10)
if (!Number.isFinite(parsedDurationValue) || parsedDurationValue < 1) {
throw new Error('Duration must be a positive integer')
}
const startDateRaw = rest.muteStartDate
const parsedStartDate =
startDateRaw === undefined || startDateRaw === ''
? undefined
: typeof startDateRaw === 'number'
? startDateRaw
: Number.parseInt(String(startDateRaw), 10)
if (parsedStartDate !== undefined && !Number.isFinite(parsedStartDate)) {
throw new Error('Start date must be a Unix epoch in seconds')
}
return {
awsRegion,
awsAccessKeyId,
awsSecretAccessKey,
muteRuleName: rest.muteRuleName,
alarmNames: names,
durationValue: parsedDurationValue,
durationUnit: rest.durationUnit,
...(rest.muteDescription && { description: rest.muteDescription }),
...(parsedStartDate !== undefined && { startDate: parsedStartDate }),
}
}
case 'unmute_alarm': {
return {
awsRegion,
awsAccessKeyId,
awsSecretAccessKey,
muteRuleName: rest.muteRuleName,
}
}
@@ -700,7 +779,18 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
description: 'Alarm state filter (OK, ALARM, INSUFFICIENT_DATA)',
},
alarmType: { type: 'string', description: 'Alarm type filter (MetricAlarm, CompositeAlarm)' },
alarmNames: { type: 'string', description: 'Comma-separated alarm names to mute or unmute' },
muteRuleName: { type: 'string', description: 'Unique name for the alarm mute rule' },
alarmNames: { type: 'string', description: 'Comma-separated alarm names to mute' },
durationValue: { type: 'number', description: 'Length of the mute window' },
durationUnit: {
type: 'string',
description: 'Unit for durationValue: minutes, hours, or days',
},
muteDescription: { type: 'string', description: 'Description of the mute rule' },
muteStartDate: {
type: 'number',
description: 'When the mute begins (Unix epoch seconds). Defaults to now.',
},
limit: { type: 'number', description: 'Maximum number of results' },
},
outputs: {
@@ -746,7 +836,19 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
},
alarmNames: {
type: 'array',
description: 'Names of the alarms that were muted or unmuted',
description: 'Names of the alarms targeted by the mute rule',
},
muteRuleName: {
type: 'string',
description: 'Name of the alarm mute rule that was created or deleted',
},
expression: {
type: 'string',
description: 'Schedule expression used by the mute rule',
},
duration: {
type: 'string',
description: 'ISO 8601 duration of the mute window',
},
success: {
type: 'boolean',
@@ -7,26 +7,58 @@ import type {
import { defineRouteContract } from '@/lib/api/contracts/types'
import { validateAwsRegion } from '@/lib/core/security/input-validation'
const MuteAlarmSchema = z.object({
region: z
.string()
.min(1, 'AWS region is required')
.refine((v) => validateAwsRegion(v).isValid, {
message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)',
}),
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
alarmNames: z
.array(z.string().min(1, 'Alarm name cannot be empty'))
.min(1, 'At least one alarm name is required')
.max(100, 'At most 100 alarm names are allowed per request'),
})
const MAX_MUTE_MINUTES = 15 * 24 * 60
const MuteAlarmSchema = z
.object({
region: z
.string()
.min(1, 'AWS region is required')
.refine((v) => validateAwsRegion(v).isValid, {
message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)',
}),
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
muteRuleName: z
.string()
.min(1, 'muteRuleName cannot be empty')
.max(255, 'muteRuleName must be at most 255 characters'),
alarmNames: z
.array(z.string().min(1, 'Alarm name cannot be empty').max(255))
.min(1, 'At least one alarm name is required')
.max(100, 'At most 100 alarm names are allowed per mute rule'),
durationValue: z
.number()
.int('durationValue must be an integer')
.min(1, 'durationValue must be at least 1'),
durationUnit: z.enum(['minutes', 'hours', 'days']),
description: z.string().max(1024).optional(),
startDate: z
.number()
.int('startDate must be an integer')
.min(0, 'startDate must be a non-negative Unix epoch in seconds')
.optional(),
})
.superRefine((data, ctx) => {
const minutesPerUnit = { minutes: 1, hours: 60, days: 1440 } as const
const totalMinutes = data.durationValue * minutesPerUnit[data.durationUnit]
if (totalMinutes > MAX_MUTE_MINUTES) {
ctx.addIssue({
code: 'custom',
message: 'duration must be at most 15 days (CloudWatch mute rule limit)',
path: ['durationValue'],
})
}
})
const MuteAlarmResponseSchema = z.object({
success: z.literal(true),
output: z.object({
success: z.literal(true),
muteRuleName: z.string(),
alarmNames: z.array(z.string()),
expression: z.string(),
duration: z.string(),
}),
})
@@ -16,17 +16,17 @@ const UnmuteAlarmSchema = z.object({
}),
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
alarmNames: z
.array(z.string().min(1, 'Alarm name cannot be empty'))
.min(1, 'At least one alarm name is required')
.max(100, 'At most 100 alarm names are allowed per request'),
muteRuleName: z
.string()
.min(1, 'muteRuleName cannot be empty')
.max(255, 'muteRuleName must be at most 255 characters'),
})
const UnmuteAlarmResponseSchema = z.object({
success: z.literal(true),
output: z.object({
success: z.literal(true),
alarmNames: z.array(z.string()),
muteRuleName: z.string(),
}),
})
+44 -5
View File
@@ -7,7 +7,7 @@ import type { ToolConfig } from '@/tools/types'
export const muteAlarmTool: ToolConfig<CloudWatchMuteAlarmParams, CloudWatchMuteAlarmResponse> = {
id: 'cloudwatch_mute_alarm',
name: 'CloudWatch Mute Alarm',
description: 'Disable notification actions on one or more CloudWatch alarms',
description: 'Create a CloudWatch alarm mute rule that suppresses alarms for a fixed duration',
version: '1.0.0',
params: {
@@ -29,11 +29,42 @@ export const muteAlarmTool: ToolConfig<CloudWatchMuteAlarmParams, CloudWatchMute
visibility: 'user-only',
description: 'AWS secret access key',
},
muteRuleName: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Unique name for the mute rule (used later to unmute)',
},
alarmNames: {
type: 'array',
required: true,
visibility: 'user-or-llm',
description: 'Names of the CloudWatch alarms to mute',
description: 'Names of the CloudWatch alarms this mute rule targets',
},
durationValue: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'How long the mute lasts (paired with durationUnit)',
},
durationUnit: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Unit for durationValue: minutes, hours, or days',
},
description: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Optional description of why the alarms are being muted',
},
startDate: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description:
'When the mute window begins as Unix epoch seconds. Defaults to now (mute starts immediately).',
},
},
@@ -47,7 +78,12 @@ export const muteAlarmTool: ToolConfig<CloudWatchMuteAlarmParams, CloudWatchMute
region: params.awsRegion,
accessKeyId: params.awsAccessKeyId,
secretAccessKey: params.awsSecretAccessKey,
muteRuleName: params.muteRuleName,
alarmNames: params.alarmNames,
durationValue: params.durationValue,
durationUnit: params.durationUnit,
...(params.description && { description: params.description }),
...(params.startDate !== undefined && { startDate: params.startDate }),
}),
},
@@ -55,7 +91,7 @@ export const muteAlarmTool: ToolConfig<CloudWatchMuteAlarmParams, CloudWatchMute
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to mute CloudWatch alarm')
throw new Error(data.error || 'Failed to create CloudWatch alarm mute rule')
}
return {
@@ -65,11 +101,14 @@ export const muteAlarmTool: ToolConfig<CloudWatchMuteAlarmParams, CloudWatchMute
},
outputs: {
success: { type: 'boolean', description: 'Whether the alarms were muted successfully' },
success: { type: 'boolean', description: 'Whether the mute rule was created successfully' },
muteRuleName: { type: 'string', description: 'Name of the mute rule that was created' },
alarmNames: {
type: 'array',
description: 'Names of the alarms that were muted',
description: 'Names of the alarms this rule mutes',
items: { type: 'string' },
},
expression: { type: 'string', description: 'Schedule expression used by the mute rule' },
duration: { type: 'string', description: 'ISO 8601 duration of the mute window' },
},
}
+12 -2
View File
@@ -164,24 +164,34 @@ export interface CloudWatchPutMetricDataResponse extends ToolResponse {
}
}
export type CloudWatchMuteDurationUnit = 'minutes' | 'hours' | 'days'
export interface CloudWatchMuteAlarmParams extends CloudWatchConnectionConfig {
muteRuleName: string
alarmNames: string[]
durationValue: number
durationUnit: CloudWatchMuteDurationUnit
description?: string
startDate?: number
}
export interface CloudWatchMuteAlarmResponse extends ToolResponse {
output: {
success: boolean
muteRuleName: string
alarmNames: string[]
expression: string
duration: string
}
}
export interface CloudWatchUnmuteAlarmParams extends CloudWatchConnectionConfig {
alarmNames: string[]
muteRuleName: string
}
export interface CloudWatchUnmuteAlarmResponse extends ToolResponse {
output: {
success: boolean
alarmNames: string[]
muteRuleName: string
}
}
+8 -12
View File
@@ -10,7 +10,7 @@ export const unmuteAlarmTool: ToolConfig<
> = {
id: 'cloudwatch_unmute_alarm',
name: 'CloudWatch Unmute Alarm',
description: 'Re-enable notification actions on one or more CloudWatch alarms',
description: 'Delete a CloudWatch alarm mute rule, restoring alarm notifications',
version: '1.0.0',
params: {
@@ -32,11 +32,11 @@ export const unmuteAlarmTool: ToolConfig<
visibility: 'user-only',
description: 'AWS secret access key',
},
alarmNames: {
type: 'array',
muteRuleName: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Names of the CloudWatch alarms to unmute',
description: 'Name of the mute rule to delete',
},
},
@@ -50,7 +50,7 @@ export const unmuteAlarmTool: ToolConfig<
region: params.awsRegion,
accessKeyId: params.awsAccessKeyId,
secretAccessKey: params.awsSecretAccessKey,
alarmNames: params.alarmNames,
muteRuleName: params.muteRuleName,
}),
},
@@ -58,7 +58,7 @@ export const unmuteAlarmTool: ToolConfig<
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to unmute CloudWatch alarm')
throw new Error(data.error || 'Failed to delete CloudWatch alarm mute rule')
}
return {
@@ -68,11 +68,7 @@ export const unmuteAlarmTool: ToolConfig<
},
outputs: {
success: { type: 'boolean', description: 'Whether the alarms were unmuted successfully' },
alarmNames: {
type: 'array',
description: 'Names of the alarms that were unmuted',
items: { type: 'string' },
},
success: { type: 'boolean', description: 'Whether the mute rule was deleted successfully' },
muteRuleName: { type: 'string', description: 'Name of the mute rule that was deleted' },
},
}