Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 44 additions & 6 deletions apps/sim/app/api/tools/cloudwatch/mute-alarm/route.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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})`
}
Comment thread
TheodoreSpeaks marked this conversation as resolved.

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)
Expand All @@ -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,
Expand All @@ -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 },
})
Comment thread
TheodoreSpeaks marked this conversation as resolved.

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 {
Expand All @@ -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 }
)
}
Expand Down
14 changes: 7 additions & 7 deletions apps/sim/app/api/tools/cloudwatch/unmute-alarm/route.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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,
Expand All @@ -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 {
Expand All @@ -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 }
)
}
Expand Down
114 changes: 108 additions & 6 deletions apps/sim/blocks/blocks/cloudwatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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')
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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',
Expand Down
60 changes: 46 additions & 14 deletions apps/sim/lib/api/contracts/tools/aws/cloudwatch-mute-alarm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}),
})

Expand Down
10 changes: 5 additions & 5 deletions apps/sim/lib/api/contracts/tools/aws/cloudwatch-unmute-alarm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}),
})

Expand Down
Loading
Loading