Skip to content

Commit 4a9e248

Browse files
feat(cloudwatch): add mute and unmute alarm operations (#4602)
1 parent 044e034 commit 4a9e248

11 files changed

Lines changed: 450 additions & 3 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { CloudWatchClient, DisableAlarmActionsCommand } from '@aws-sdk/client-cloudwatch'
2+
import { createLogger } from '@sim/logger'
3+
import { toError } from '@sim/utils/errors'
4+
import { type NextRequest, NextResponse } from 'next/server'
5+
import { awsCloudwatchMuteAlarmContract } from '@/lib/api/contracts/tools/aws/cloudwatch-mute-alarm'
6+
import { parseToolRequest } from '@/lib/api/server'
7+
import { checkInternalAuth } from '@/lib/auth/hybrid'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
10+
const logger = createLogger('CloudWatchMuteAlarm')
11+
12+
export const POST = withRouteHandler(async (request: NextRequest) => {
13+
try {
14+
const auth = await checkInternalAuth(request)
15+
if (!auth.success || !auth.userId) {
16+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
17+
}
18+
19+
const parsed = await parseToolRequest(awsCloudwatchMuteAlarmContract, request, {
20+
errorFormat: 'details',
21+
logger,
22+
})
23+
if (!parsed.success) return parsed.response
24+
const validatedData = parsed.data.body
25+
26+
logger.info(`Muting ${validatedData.alarmNames.length} CloudWatch alarm(s)`)
27+
28+
const client = new CloudWatchClient({
29+
region: validatedData.region,
30+
credentials: {
31+
accessKeyId: validatedData.accessKeyId,
32+
secretAccessKey: validatedData.secretAccessKey,
33+
},
34+
})
35+
36+
try {
37+
const command = new DisableAlarmActionsCommand({
38+
AlarmNames: validatedData.alarmNames,
39+
})
40+
41+
await client.send(command)
42+
43+
logger.info(`Successfully muted ${validatedData.alarmNames.length} alarm(s)`)
44+
45+
return NextResponse.json({
46+
success: true,
47+
output: {
48+
success: true,
49+
alarmNames: validatedData.alarmNames,
50+
},
51+
})
52+
} finally {
53+
client.destroy()
54+
}
55+
} catch (error) {
56+
logger.error('MuteAlarm failed', { error: toError(error).message })
57+
return NextResponse.json(
58+
{ error: `Failed to mute CloudWatch alarm: ${toError(error).message}` },
59+
{ status: 500 }
60+
)
61+
}
62+
})
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { CloudWatchClient, EnableAlarmActionsCommand } from '@aws-sdk/client-cloudwatch'
2+
import { createLogger } from '@sim/logger'
3+
import { toError } from '@sim/utils/errors'
4+
import { type NextRequest, NextResponse } from 'next/server'
5+
import { awsCloudwatchUnmuteAlarmContract } from '@/lib/api/contracts/tools/aws/cloudwatch-unmute-alarm'
6+
import { parseToolRequest } from '@/lib/api/server'
7+
import { checkInternalAuth } from '@/lib/auth/hybrid'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
10+
const logger = createLogger('CloudWatchUnmuteAlarm')
11+
12+
export const POST = withRouteHandler(async (request: NextRequest) => {
13+
try {
14+
const auth = await checkInternalAuth(request)
15+
if (!auth.success || !auth.userId) {
16+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
17+
}
18+
19+
const parsed = await parseToolRequest(awsCloudwatchUnmuteAlarmContract, request, {
20+
errorFormat: 'details',
21+
logger,
22+
})
23+
if (!parsed.success) return parsed.response
24+
const validatedData = parsed.data.body
25+
26+
logger.info(`Unmuting ${validatedData.alarmNames.length} CloudWatch alarm(s)`)
27+
28+
const client = new CloudWatchClient({
29+
region: validatedData.region,
30+
credentials: {
31+
accessKeyId: validatedData.accessKeyId,
32+
secretAccessKey: validatedData.secretAccessKey,
33+
},
34+
})
35+
36+
try {
37+
const command = new EnableAlarmActionsCommand({
38+
AlarmNames: validatedData.alarmNames,
39+
})
40+
41+
await client.send(command)
42+
43+
logger.info(`Successfully unmuted ${validatedData.alarmNames.length} alarm(s)`)
44+
45+
return NextResponse.json({
46+
success: true,
47+
output: {
48+
success: true,
49+
alarmNames: validatedData.alarmNames,
50+
},
51+
})
52+
} finally {
53+
client.destroy()
54+
}
55+
} catch (error) {
56+
logger.error('UnmuteAlarm failed', { error: toError(error).message })
57+
return NextResponse.json(
58+
{ error: `Failed to unmute CloudWatch alarm: ${toError(error).message}` },
59+
{ status: 500 }
60+
)
61+
}
62+
})

apps/sim/blocks/blocks/cloudwatch.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import type {
88
CloudWatchGetLogEventsResponse,
99
CloudWatchGetMetricStatisticsResponse,
1010
CloudWatchListMetricsResponse,
11+
CloudWatchMuteAlarmResponse,
1112
CloudWatchPutMetricDataResponse,
1213
CloudWatchQueryLogsResponse,
14+
CloudWatchUnmuteAlarmResponse,
1315
} from '@/tools/cloudwatch/types'
1416

1517
export const CloudWatchBlock: BlockConfig<
@@ -21,6 +23,8 @@ export const CloudWatchBlock: BlockConfig<
2123
| CloudWatchListMetricsResponse
2224
| CloudWatchGetMetricStatisticsResponse
2325
| CloudWatchPutMetricDataResponse
26+
| CloudWatchMuteAlarmResponse
27+
| CloudWatchUnmuteAlarmResponse
2428
> = {
2529
type: 'cloudwatch',
2630
name: 'CloudWatch',
@@ -47,6 +51,8 @@ export const CloudWatchBlock: BlockConfig<
4751
{ label: 'Get Metric Statistics', id: 'get_metric_statistics' },
4852
{ label: 'Publish Metric', id: 'put_metric_data' },
4953
{ label: 'Describe Alarms', id: 'describe_alarms' },
54+
{ label: 'Mute Alarm', id: 'mute_alarm' },
55+
{ label: 'Unmute Alarm', id: 'unmute_alarm' },
5056
],
5157
value: () => 'query_logs',
5258
},
@@ -360,6 +366,14 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
360366
value: () => '',
361367
condition: { field: 'operation', value: 'describe_alarms' },
362368
},
369+
{
370+
id: 'alarmNames',
371+
title: 'Alarm Names',
372+
type: 'short-input',
373+
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'] },
376+
},
363377
{
364378
id: 'limit',
365379
title: 'Limit',
@@ -389,6 +403,8 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
389403
'cloudwatch_get_metric_statistics',
390404
'cloudwatch_put_metric_data',
391405
'cloudwatch_describe_alarms',
406+
'cloudwatch_mute_alarm',
407+
'cloudwatch_unmute_alarm',
392408
],
393409
config: {
394410
tool: (params) => {
@@ -409,6 +425,10 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
409425
return 'cloudwatch_put_metric_data'
410426
case 'describe_alarms':
411427
return 'cloudwatch_describe_alarms'
428+
case 'mute_alarm':
429+
return 'cloudwatch_mute_alarm'
430+
case 'unmute_alarm':
431+
return 'cloudwatch_unmute_alarm'
412432
default:
413433
throw new Error(`Invalid CloudWatch operation: ${params.operation}`)
414434
}
@@ -613,6 +633,33 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
613633
...(parsedLimit !== undefined && { limit: parsedLimit }),
614634
}
615635

636+
case 'mute_alarm':
637+
case 'unmute_alarm': {
638+
const alarmNames = rest.alarmNames
639+
if (!alarmNames) {
640+
throw new Error('Alarm names are required')
641+
}
642+
643+
const names =
644+
typeof alarmNames === 'string'
645+
? alarmNames
646+
.split(',')
647+
.map((n: string) => n.trim())
648+
.filter(Boolean)
649+
: alarmNames
650+
651+
if (!Array.isArray(names) || names.length === 0) {
652+
throw new Error('At least one alarm name is required')
653+
}
654+
655+
return {
656+
awsRegion,
657+
awsAccessKeyId,
658+
awsSecretAccessKey,
659+
alarmNames: names,
660+
}
661+
}
662+
616663
default:
617664
throw new Error(`Invalid CloudWatch operation: ${operation}`)
618665
}
@@ -653,6 +700,7 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
653700
description: 'Alarm state filter (OK, ALARM, INSUFFICIENT_DATA)',
654701
},
655702
alarmType: { type: 'string', description: 'Alarm type filter (MetricAlarm, CompositeAlarm)' },
703+
alarmNames: { type: 'string', description: 'Comma-separated alarm names to mute or unmute' },
656704
limit: { type: 'number', description: 'Maximum number of results' },
657705
},
658706
outputs: {
@@ -696,9 +744,13 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
696744
type: 'array',
697745
description: 'CloudWatch alarms with state and configuration',
698746
},
747+
alarmNames: {
748+
type: 'array',
749+
description: 'Names of the alarms that were muted or unmuted',
750+
},
699751
success: {
700752
type: 'boolean',
701-
description: 'Whether the published metric was successful',
753+
description: 'Whether the operation completed successfully',
702754
},
703755
namespace: {
704756
type: 'string',
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { z } from 'zod'
2+
import type {
3+
ContractBody,
4+
ContractBodyInput,
5+
ContractJsonResponse,
6+
} from '@/lib/api/contracts/types'
7+
import { defineRouteContract } from '@/lib/api/contracts/types'
8+
import { validateAwsRegion } from '@/lib/core/security/input-validation'
9+
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+
})
24+
25+
const MuteAlarmResponseSchema = z.object({
26+
success: z.literal(true),
27+
output: z.object({
28+
success: z.literal(true),
29+
alarmNames: z.array(z.string()),
30+
}),
31+
})
32+
33+
export const awsCloudwatchMuteAlarmContract = defineRouteContract({
34+
method: 'POST',
35+
path: '/api/tools/cloudwatch/mute-alarm',
36+
body: MuteAlarmSchema,
37+
response: { mode: 'json', schema: MuteAlarmResponseSchema },
38+
})
39+
export type AwsCloudwatchMuteAlarmRequest = ContractBodyInput<typeof awsCloudwatchMuteAlarmContract>
40+
export type AwsCloudwatchMuteAlarmBody = ContractBody<typeof awsCloudwatchMuteAlarmContract>
41+
export type AwsCloudwatchMuteAlarmResponse = ContractJsonResponse<
42+
typeof awsCloudwatchMuteAlarmContract
43+
>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { z } from 'zod'
2+
import type {
3+
ContractBody,
4+
ContractBodyInput,
5+
ContractJsonResponse,
6+
} from '@/lib/api/contracts/types'
7+
import { defineRouteContract } from '@/lib/api/contracts/types'
8+
import { validateAwsRegion } from '@/lib/core/security/input-validation'
9+
10+
const UnmuteAlarmSchema = 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+
})
24+
25+
const UnmuteAlarmResponseSchema = z.object({
26+
success: z.literal(true),
27+
output: z.object({
28+
success: z.literal(true),
29+
alarmNames: z.array(z.string()),
30+
}),
31+
})
32+
33+
export const awsCloudwatchUnmuteAlarmContract = defineRouteContract({
34+
method: 'POST',
35+
path: '/api/tools/cloudwatch/unmute-alarm',
36+
body: UnmuteAlarmSchema,
37+
response: { mode: 'json', schema: UnmuteAlarmResponseSchema },
38+
})
39+
export type AwsCloudwatchUnmuteAlarmRequest = ContractBodyInput<
40+
typeof awsCloudwatchUnmuteAlarmContract
41+
>
42+
export type AwsCloudwatchUnmuteAlarmBody = ContractBody<typeof awsCloudwatchUnmuteAlarmContract>
43+
export type AwsCloudwatchUnmuteAlarmResponse = ContractJsonResponse<
44+
typeof awsCloudwatchUnmuteAlarmContract
45+
>

apps/sim/tools/cloudwatch/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import { describeLogStreamsTool } from '@/tools/cloudwatch/describe_log_streams'
44
import { getLogEventsTool } from '@/tools/cloudwatch/get_log_events'
55
import { getMetricStatisticsTool } from '@/tools/cloudwatch/get_metric_statistics'
66
import { listMetricsTool } from '@/tools/cloudwatch/list_metrics'
7+
import { muteAlarmTool } from '@/tools/cloudwatch/mute_alarm'
78
import { putMetricDataTool } from '@/tools/cloudwatch/put_metric_data'
89
import { queryLogsTool } from '@/tools/cloudwatch/query_logs'
10+
import { unmuteAlarmTool } from '@/tools/cloudwatch/unmute_alarm'
911

1012
export * from './types'
1113

@@ -15,5 +17,7 @@ export const cloudwatchDescribeLogStreamsTool = describeLogStreamsTool
1517
export const cloudwatchGetLogEventsTool = getLogEventsTool
1618
export const cloudwatchGetMetricStatisticsTool = getMetricStatisticsTool
1719
export const cloudwatchListMetricsTool = listMetricsTool
20+
export const cloudwatchMuteAlarmTool = muteAlarmTool
1821
export const cloudwatchPutMetricDataTool = putMetricDataTool
1922
export const cloudwatchQueryLogsTool = queryLogsTool
23+
export const cloudwatchUnmuteAlarmTool = unmuteAlarmTool

0 commit comments

Comments
 (0)