feat(api): add CloudWatch alarms and metric charts to the cloud explorer
CI / checks (pull_request) Successful in 27m29s

This commit is contained in:
Zakaria
2026-10-24 22:48:41 -04:00
parent f4b3e45f3d
commit 6dd33b8f65
26 changed files with 1551 additions and 1 deletions
@@ -0,0 +1,147 @@
import {describe, expect, test} from 'bun:test'
import {
DeleteAlarmsCommand,
DescribeAlarmHistoryCommand,
DescribeAlarmsCommand,
DisableAlarmActionsCommand,
EnableAlarmActionsCommand,
GetMetricDataCommand,
ListMetricsCommand,
PutCompositeAlarmCommand,
PutMetricAlarmCommand,
SetAlarmStateCommand,
type CloudWatchClient,
} from '@aws-sdk/client-cloudwatch'
import {AwsMonitoringAdapter} from './AwsMonitoringAdapter'
import {ValidationError} from '../cloud-spi/errors'
type SendResult = Record<string, unknown>
function stubCloudWatch(handler: (command: object) => SendResult | Promise<SendResult>) {
const sent: object[] = []
const client = {
async send(command: object) {
sent.push(command)
return handler(command)
},
} as unknown as CloudWatchClient
return {adapter: new AwsMonitoringAdapter(client), sent}
}
const UPDATED = new Date('2026-10-25T10:00:00Z')
const metricAlarm = {
AlarmName: 'high-errors', AlarmArn: 'arn:aws:cloudwatch:us-east-1:000000000000:alarm:high-errors',
StateValue: 'ALARM', StateReason: 'Threshold crossed', StateUpdatedTimestamp: UPDATED, AlarmConfigurationUpdatedTimestamp: UPDATED,
ActionsEnabled: true, AlarmActions: ['arn:aws:sns:us-east-1:000000000000:alerts'], OKActions: [],
Namespace: 'App', MetricName: 'Errors', Dimensions: [{Name: 'FunctionName', Value: 'checkout'}],
Statistic: 'Sum', Period: 300, EvaluationPeriods: 2, Threshold: 5, ComparisonOperator: 'GreaterThanThreshold',
}
const compositeAlarm = {
AlarmName: 'app-unhealthy', AlarmArn: 'arn:aws:cloudwatch:us-east-1:000000000000:alarm:app-unhealthy',
StateValue: 'OK', AlarmRule: 'ALARM("high-errors")', ActionsEnabled: false, AlarmConfigurationUpdatedTimestamp: UPDATED,
}
describe('AwsMonitoringAdapter alarms', () => {
test('lists metric and composite alarms with a readable condition', async () => {
const {adapter, sent} = stubCloudWatch((command) => command instanceof DescribeAlarmsCommand
? {MetricAlarms: [metricAlarm], CompositeAlarms: [compositeAlarm]}
: {})
const alarms = await adapter.list()
expect((sent[0] as DescribeAlarmsCommand).input.AlarmTypes).toEqual(['MetricAlarm', 'CompositeAlarm'])
expect(alarms.map((alarm) => [alarm.name, alarm.status, alarm.metadata.alarmType])).toEqual([['app-unhealthy', 'OK', 'composite'], ['high-errors', 'ALARM', 'metric']])
expect(alarms[1].metadata).toMatchObject({condition: 'Errors Sum > 5 for 2 × 5 min', dimensions: [{name: 'FunctionName', value: 'checkout'}], threshold: 5, periodSeconds: 300})
expect(alarms[0].metadata).toMatchObject({condition: 'ALARM("high-errors")', actionsEnabled: false})
expect(await adapter.list({search: 'errors'})).toHaveLength(1)
})
test('creates a metric alarm that notifies an SNS topic both ways', async () => {
const {adapter, sent} = stubCloudWatch((command) => command instanceof DescribeAlarmsCommand ? {MetricAlarms: [metricAlarm]} : {})
await adapter.create({values: {
alarmType: 'metric', name: 'high-errors', namespace: 'App', metricName: 'Errors', dimensions: 'FunctionName=checkout\n',
statistic: 'Sum', period: '300', comparison: 'GreaterThanThreshold', threshold: '5', evaluationPeriods: '2',
topicArn: 'arn:aws:sns:us-east-1:000000000000:alerts',
}})
expect((sent[0] as PutMetricAlarmCommand).input).toMatchObject({
AlarmName: 'high-errors', Namespace: 'App', MetricName: 'Errors', Dimensions: [{Name: 'FunctionName', Value: 'checkout'}],
Statistic: 'Sum', Period: 300, EvaluationPeriods: 2, Threshold: 5, ComparisonOperator: 'GreaterThanThreshold',
AlarmActions: ['arn:aws:sns:us-east-1:000000000000:alerts'], OKActions: ['arn:aws:sns:us-east-1:000000000000:alerts'],
})
})
test('creates a composite alarm from its rule', async () => {
const {adapter, sent} = stubCloudWatch((command) => command instanceof DescribeAlarmsCommand ? {CompositeAlarms: [compositeAlarm]} : {})
const created = await adapter.create({values: {alarmType: 'composite', name: 'app-unhealthy', alarmRule: 'ALARM("high-errors")'}})
expect((sent[0] as PutCompositeAlarmCommand).input).toMatchObject({AlarmName: 'app-unhealthy', AlarmRule: 'ALARM("high-errors")', AlarmActions: []})
expect(created.metadata.alarmType).toBe('composite')
})
test('checks the alarm before creating it', async () => {
const {adapter, sent} = stubCloudWatch(() => ({}))
const valid = {alarmType: 'metric', name: 'a', namespace: 'App', metricName: 'Errors', threshold: '5'}
await expect(adapter.create({values: {...valid, threshold: 'lots'}})).rejects.toThrow(/threshold/)
await expect(adapter.create({values: {...valid, metricName: ''}})).rejects.toThrow(/metricName/)
await expect(adapter.create({values: {...valid, dimensions: 'FunctionName'}})).rejects.toThrow(/Name=Value/)
await expect(adapter.create({values: {...valid, topicArn: 'my-topic'}})).rejects.toThrow(/SNS topic ARN/)
await expect(adapter.create({values: {...valid, period: '7'}})).rejects.toThrow(ValidationError)
await expect(adapter.create({values: {alarmType: 'composite', name: 'b', alarmRule: ' '}})).rejects.toThrow(/rule/)
expect(sent.some((command) => command instanceof PutMetricAlarmCommand || command instanceof PutCompositeAlarmCommand)).toBe(false)
})
test('deletes, sets the state and toggles the actions of an alarm', async () => {
const {adapter, sent} = stubCloudWatch(() => ({}))
await adapter.delete('high-errors')
await adapter.setAlarmState('high-errors', 'ALARM', '')
await adapter.setAlarmActionsEnabled('high-errors', false)
await adapter.setAlarmActionsEnabled('high-errors', true)
expect((sent[0] as DeleteAlarmsCommand).input).toEqual({AlarmNames: ['high-errors']})
expect((sent[1] as SetAlarmStateCommand).input).toMatchObject({AlarmName: 'high-errors', StateValue: 'ALARM'})
expect((sent[1] as SetAlarmStateCommand).input.StateReason).toContain('TheCloud')
expect(sent[2]).toBeInstanceOf(DisableAlarmActionsCommand)
expect(sent[3]).toBeInstanceOf(EnableAlarmActionsCommand)
await expect(adapter.setAlarmState('high-errors', 'BROKEN' as never, '')).rejects.toThrow(/state/)
})
test('reads the alarm history newest first', async () => {
const {adapter, sent} = stubCloudWatch(() => ({AlarmHistoryItems: [{Timestamp: UPDATED, HistoryItemType: 'StateUpdate', HistorySummary: 'Alarm updated from OK to ALARM'}]}))
expect(await adapter.describeAlarmHistory('high-errors')).toEqual([{timestamp: '2026-10-25T10:00:00.000Z', type: 'StateUpdate', summary: 'Alarm updated from OK to ALARM'}])
expect((sent[0] as DescribeAlarmHistoryCommand).input).toMatchObject({AlarmName: 'high-errors', ScanBy: 'TimestampDescending'})
})
})
describe('AwsMonitoringAdapter metrics', () => {
test('lists metrics sorted by namespace and filters them', async () => {
const {adapter} = stubCloudWatch((command) => command instanceof ListMetricsCommand
? {Metrics: [
{Namespace: 'AWS/SQS', MetricName: 'NumberOfMessagesSent', Dimensions: [{Name: 'QueueName', Value: 'orders'}]},
{Namespace: 'App', MetricName: 'Errors', Dimensions: []},
]}
: {})
expect((await adapter.listMetrics()).map((metric) => `${metric.namespace}/${metric.metricName}`)).toEqual(['App/Errors', 'AWS/SQS/NumberOfMessagesSent'])
expect(await adapter.listMetrics({search: 'orders'})).toHaveLength(1)
})
test('charts a metric with a period that suits the range', async () => {
const {adapter, sent} = stubCloudWatch((command) => command instanceof GetMetricDataCommand
? {MetricDataResults: [{Id: 'm1', Label: 'Errors', Timestamps: [new Date('2026-10-25T10:01:00Z'), new Date('2026-10-25T10:00:00Z')], Values: [7, 3]}]}
: {})
const series = await adapter.getMetricSeries({namespace: 'App', metricName: 'Errors', statistic: 'Sum', rangeMinutes: 60, dimensions: [{name: 'FunctionName', value: 'checkout'}]})
expect(series).toEqual({label: 'Errors', statistic: 'Sum', periodSeconds: 60, points: [{timestamp: '2026-10-25T10:00:00.000Z', value: 3}, {timestamp: '2026-10-25T10:01:00.000Z', value: 7}]})
const query = (sent[0] as GetMetricDataCommand).input.MetricDataQueries![0]
expect(query.MetricStat).toEqual({Metric: {Namespace: 'App', MetricName: 'Errors', Dimensions: [{Name: 'FunctionName', Value: 'checkout'}]}, Period: 60, Stat: 'Sum'})
await adapter.getMetricSeries({namespace: 'App', metricName: 'Errors', rangeMinutes: 24 * 60})
expect((sent[1] as GetMetricDataCommand).input.MetricDataQueries![0].MetricStat!.Period).toBe(300)
await expect(adapter.getMetricSeries({namespace: 'App', metricName: 'Errors', rangeMinutes: 1})).rejects.toThrow(/rangeMinutes/)
await expect(adapter.getMetricSeries({namespace: '', metricName: 'Errors'})).rejects.toThrow(/namespace/)
})
})
@@ -0,0 +1,326 @@
import {
DeleteAlarmsCommand,
DescribeAlarmHistoryCommand,
DescribeAlarmsCommand,
DisableAlarmActionsCommand,
EnableAlarmActionsCommand,
GetMetricDataCommand,
ListMetricsCommand,
PutCompositeAlarmCommand,
PutMetricAlarmCommand,
SetAlarmStateCommand,
type CloudWatchClient,
type ComparisonOperator,
type CompositeAlarm,
type MetricAlarm,
type Statistic,
} from '@aws-sdk/client-cloudwatch'
import {
ALARM_COMPARISONS,
ALARM_NAME_PATTERN,
ALARM_PERIODS,
ALARM_STATES,
ALARM_STATISTICS,
ALARM_TYPES,
awsMonitoringSchema,
} from '../cloud-spi/monitoringSchema'
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
import type {
AlarmHistoryItem,
AlarmStateValue,
CloudResource,
CloudServiceAdapter,
CreateResourceInput,
MetricDimension,
MetricSeries,
MetricSeriesQuery,
MetricSummary,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
/** CloudWatch's page and request limits. */
const ALARM_PAGE_LIMIT = 100
const METRIC_LIST_CAP = 500
const HISTORY_LIMIT = 25
const MAX_DIMENSIONS = 30
const MAX_RANGE_MINUTES = 14 * 24 * 60
const SYMBOL: Record<string, string> = {
GreaterThanThreshold: '>',
GreaterThanOrEqualToThreshold: '>=',
LessThanThreshold: '<',
LessThanOrEqualToThreshold: '<=',
}
/**
* Talks to CloudWatch metrics and alarms. Alarms, metric and composite, are the resources;
* metrics are browsed and charted through the monitoring actions.
*
* Verified against TheCloud's runtime: alarm actions publish to SNS on every state change,
* `SetAlarmState` changes the state until the next evaluation, and `PutMetricData` datums
* are returned by `GetMetricData` straight away.
*/
export class AwsMonitoringAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'monitoring' as const
constructor(private readonly cloudwatch: CloudWatchClient) {}
schema(): ServiceSchema {
return awsMonitoringSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const metricAlarms: MetricAlarm[] = []
const compositeAlarms: CompositeAlarm[] = []
let nextToken: string | undefined
do {
const res = await this.cloudwatch.send(new DescribeAlarmsCommand({
AlarmTypes: ['MetricAlarm', 'CompositeAlarm'],
MaxRecords: ALARM_PAGE_LIMIT,
...(nextToken ? {NextToken: nextToken} : {}),
}))
metricAlarms.push(...(res.MetricAlarms ?? []))
compositeAlarms.push(...(res.CompositeAlarms ?? []))
nextToken = res.NextToken || undefined
} while (nextToken)
const search = query.search?.trim().toLowerCase()
return [...metricAlarms.map(metricAlarmResource), ...compositeAlarms.map(compositeAlarmResource)]
.filter((alarm) => !search || [alarm.name, String(alarm.metadata.metricName ?? ''), String(alarm.metadata.namespace ?? '')].some((value) => value.toLowerCase().includes(search)))
.sort((a, b) => a.name.localeCompare(b.name))
}
async get(name: string): Promise<CloudResource | null> {
const res = await this.cloudwatch.send(new DescribeAlarmsCommand({AlarmNames: [name], AlarmTypes: ['MetricAlarm', 'CompositeAlarm']}))
const metric = res.MetricAlarms?.[0]
if (metric) return metricAlarmResource(metric)
const composite = res.CompositeAlarms?.[0]
return composite ? compositeAlarmResource(composite) : null
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const values = input.values
const type = oneOf(values.alarmType ?? 'metric', ALARM_TYPES, 'alarmType')
const name = text(values.name)
if (!new RegExp(ALARM_NAME_PATTERN).test(name)) throw new ValidationError('name must be 1 to 255 characters, without control characters')
const description = text(values.description) || undefined
const topicArn = text(values.topicArn)
if (topicArn && !/^arn:aws[a-z-]*:sns:[a-z0-9-]+:\d{12}:[A-Za-z0-9_-]{1,256}(\.fifo)?$/.test(topicArn)) {
throw new ValidationError('topicArn must be an SNS topic ARN, such as arn:aws:sns:us-east-1:000000000000:alerts')
}
const actions = topicArn ? [topicArn] : []
if (type === 'composite') {
const rule = text(values.alarmRule)
if (!rule) throw new ValidationError('Write the rule, such as ALARM("high-errors")')
if (rule.length > 10240) throw new ValidationError('The rule must be at most 10240 characters')
await this.cloudwatch.send(new PutCompositeAlarmCommand({
AlarmName: name,
AlarmDescription: description,
AlarmRule: rule,
AlarmActions: actions,
OKActions: actions,
ActionsEnabled: true,
}))
} else {
const namespace = text(values.namespace)
const metricName = text(values.metricName)
if (!namespace) throw new ValidationError('namespace is required, such as AWS/Lambda')
if (!metricName) throw new ValidationError('metricName is required, such as Errors')
const threshold = Number(text(values.threshold))
if (text(values.threshold) === '' || !Number.isFinite(threshold)) throw new ValidationError('threshold must be a number')
const evaluationPeriods = Number(text(values.evaluationPeriods) || '1')
if (!Number.isInteger(evaluationPeriods) || evaluationPeriods < 1) throw new ValidationError('evaluationPeriods must be a whole number from 1')
await this.cloudwatch.send(new PutMetricAlarmCommand({
AlarmName: name,
AlarmDescription: description,
Namespace: namespace,
MetricName: metricName,
Dimensions: parseDimensions(values.dimensions).map((dimension) => ({Name: dimension.name, Value: dimension.value})),
Statistic: oneOf(values.statistic || 'Average', ALARM_STATISTICS, 'statistic') as Statistic,
Period: Number(oneOf(String(values.period || '300'), ALARM_PERIODS, 'period')),
EvaluationPeriods: evaluationPeriods,
Threshold: threshold,
ComparisonOperator: oneOf(values.comparison || 'GreaterThanThreshold', ALARM_COMPARISONS, 'comparison') as ComparisonOperator,
TreatMissingData: 'missing',
AlarmActions: actions,
OKActions: actions,
ActionsEnabled: true,
}))
}
const created = await this.get(name)
if (!created) throw new NotFoundError(`CloudWatch did not return the alarm ${name}`)
return created
}
async delete(name: string): Promise<void> {
await this.cloudwatch.send(new DeleteAlarmsCommand({AlarmNames: [name]}))
}
async listMetrics(query: {namespace?: string; search?: string} = {}): Promise<MetricSummary[]> {
const metrics: MetricSummary[] = []
let nextToken: string | undefined
do {
const res = await this.cloudwatch.send(new ListMetricsCommand({
...(query.namespace ? {Namespace: query.namespace} : {}),
...(nextToken ? {NextToken: nextToken} : {}),
}))
for (const metric of res.Metrics ?? []) {
metrics.push({
namespace: metric.Namespace ?? '',
metricName: metric.MetricName ?? '',
dimensions: (metric.Dimensions ?? []).map((dimension) => ({name: dimension.Name ?? '', value: dimension.Value ?? ''})),
})
}
nextToken = res.NextToken || undefined
} while (nextToken && metrics.length < METRIC_LIST_CAP)
const search = query.search?.trim().toLowerCase()
return metrics.slice(0, METRIC_LIST_CAP)
.filter((metric) => !search || [metric.namespace, metric.metricName, ...metric.dimensions.map((dimension) => dimension.value)].some((value) => value.toLowerCase().includes(search)))
.sort((a, b) => a.namespace.localeCompare(b.namespace) || a.metricName.localeCompare(b.metricName))
}
async getMetricSeries(query: MetricSeriesQuery): Promise<MetricSeries> {
const namespace = text(query?.namespace)
const metricName = text(query.metricName)
if (!namespace || !metricName) throw new ValidationError('namespace and metricName are required')
const statistic = oneOf(query.statistic || 'Average', ALARM_STATISTICS, 'statistic')
const rangeMinutes = Number(query.rangeMinutes ?? 60)
if (!Number.isFinite(rangeMinutes) || rangeMinutes < 5 || rangeMinutes > MAX_RANGE_MINUTES) throw new ValidationError('rangeMinutes must be between 5 and 20160')
// A point every minute for short ranges, coarser for long ones, staying near 60-300 points.
const periodSeconds = Number(query.periodSeconds) || (rangeMinutes <= 180 ? 60 : rangeMinutes <= 1440 ? 300 : 3600)
if (![60, 300, 900, 3600].includes(periodSeconds)) throw new ValidationError('periodSeconds must be 60, 300, 900 or 3600')
const dimensions = (query.dimensions ?? []).map((dimension) => ({Name: text(dimension?.name), Value: text(dimension?.value)}))
if (dimensions.some((dimension) => !dimension.Name)) throw new ValidationError('Every dimension needs a name')
const end = new Date()
const start = new Date(end.getTime() - rangeMinutes * 60_000)
const res = await this.cloudwatch.send(new GetMetricDataCommand({
StartTime: start,
EndTime: end,
ScanBy: 'TimestampAscending',
MetricDataQueries: [{
Id: 'm1',
ReturnData: true,
MetricStat: {Metric: {Namespace: namespace, MetricName: metricName, Dimensions: dimensions}, Period: periodSeconds, Stat: statistic},
}],
}))
const result = res.MetricDataResults?.[0]
const timestamps = result?.Timestamps ?? []
const points = timestamps.map((timestamp, index) => ({timestamp: timestamp.toISOString(), value: result?.Values?.[index] ?? 0}))
.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
return {label: result?.Label || metricName, statistic, periodSeconds, points}
}
async describeAlarmHistory(name: string): Promise<AlarmHistoryItem[]> {
const res = await this.cloudwatch.send(new DescribeAlarmHistoryCommand({AlarmName: name, MaxRecords: HISTORY_LIMIT, ScanBy: 'TimestampDescending'}))
return (res.AlarmHistoryItems ?? []).map((item) => ({
timestamp: item.Timestamp?.toISOString() ?? null,
type: item.HistoryItemType ?? '',
summary: item.HistorySummary ?? '',
}))
}
/** Changes the state until the next evaluation, which is how alarm actions are tested on AWS. */
async setAlarmState(name: string, state: AlarmStateValue, reason: string): Promise<void> {
const value = oneOf(state, ALARM_STATES, 'state')
const why = text(reason) || `Set to ${value} from TheCloud to test the alarm's actions`
if (why.length > 1023) throw new ValidationError('reason must be at most 1023 characters')
await this.cloudwatch.send(new SetAlarmStateCommand({AlarmName: name, StateValue: value, StateReason: why}))
}
async setAlarmActionsEnabled(name: string, enabled: boolean): Promise<void> {
if (typeof enabled !== 'boolean') throw new ValidationError('enabled must be true or false')
await this.cloudwatch.send(enabled ? new EnableAlarmActionsCommand({AlarmNames: [name]}) : new DisableAlarmActionsCommand({AlarmNames: [name]}))
}
}
function metricAlarmResource(alarm: MetricAlarm): CloudResource {
const dimensions = (alarm.Dimensions ?? []).map((dimension) => ({name: dimension.Name ?? '', value: dimension.Value ?? ''}))
const period = alarm.Period ?? 0
return {
...baseResource(alarm.AlarmName ?? '', alarm.AlarmArn, alarm.StateValue, alarm.AlarmConfigurationUpdatedTimestamp),
metadata: {
alarmType: 'metric',
arn: alarm.AlarmArn ?? null,
description: alarm.AlarmDescription ?? null,
stateReason: alarm.StateReason ?? null,
stateUpdatedAt: alarm.StateUpdatedTimestamp?.toISOString() ?? null,
actionsEnabled: alarm.ActionsEnabled ?? true,
alarmActions: alarm.AlarmActions ?? [],
okActions: alarm.OKActions ?? [],
namespace: alarm.Namespace ?? null,
metricName: alarm.MetricName ?? null,
dimensions,
statistic: alarm.Statistic ?? alarm.ExtendedStatistic ?? null,
periodSeconds: period || null,
threshold: alarm.Threshold ?? null,
comparison: alarm.ComparisonOperator ?? null,
evaluationPeriods: alarm.EvaluationPeriods ?? null,
condition: alarm.MetricName
? `${alarm.MetricName} ${alarm.Statistic ?? ''} ${SYMBOL[alarm.ComparisonOperator ?? ''] ?? alarm.ComparisonOperator ?? ''} ${alarm.Threshold ?? ''} for ${alarm.EvaluationPeriods ?? 1} × ${periodLabel(period)}`.replace(/\s+/g, ' ').trim()
: 'Metric math',
},
}
}
function compositeAlarmResource(alarm: CompositeAlarm): CloudResource {
return {
...baseResource(alarm.AlarmName ?? '', alarm.AlarmArn, alarm.StateValue, alarm.AlarmConfigurationUpdatedTimestamp),
metadata: {
alarmType: 'composite',
arn: alarm.AlarmArn ?? null,
description: alarm.AlarmDescription ?? null,
stateReason: alarm.StateReason ?? null,
stateUpdatedAt: alarm.StateUpdatedTimestamp?.toISOString() ?? null,
actionsEnabled: alarm.ActionsEnabled ?? true,
alarmActions: alarm.AlarmActions ?? [],
okActions: alarm.OKActions ?? [],
alarmRule: alarm.AlarmRule ?? null,
condition: alarm.AlarmRule ?? null,
},
}
}
function baseResource(name: string, arn: string | undefined, state: string | undefined, updated: Date | undefined) {
return {
id: name,
name,
cloud: 'aws' as const,
service: 'monitoring' as const,
type: 'alarm',
region: arn?.split(':')[3] || null,
createdAt: updated?.toISOString() ?? null,
status: state ?? null,
}
}
/** One Name=Value per line, as the create form takes it. */
function parseDimensions(value: unknown): MetricDimension[] {
const lines = typeof value === 'string' ? value.split('\n').map((line) => line.trim()).filter(Boolean) : []
if (lines.length > MAX_DIMENSIONS) throw new ValidationError(`An alarm takes at most ${MAX_DIMENSIONS} dimensions`)
return lines.map((line) => {
const index = line.indexOf('=')
const name = index > 0 ? line.slice(0, index).trim() : ''
const dimensionValue = index > 0 ? line.slice(index + 1).trim() : ''
if (!name || !dimensionValue) throw new ValidationError(`Write each dimension as Name=Value (got "${line}")`)
return {name, value: dimensionValue}
})
}
function periodLabel(seconds: number): string {
if (seconds >= 3600 && seconds % 3600 === 0) return `${seconds / 3600} h`
if (seconds >= 60 && seconds % 60 === 0) return `${seconds / 60} min`
return `${seconds} s`
}
function oneOf<T extends string>(value: unknown, allowed: readonly T[], field: string): T {
if (typeof value === 'string' && (allowed as readonly string[]).includes(value)) return value as T
throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`)
}
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : typeof value === 'number' ? String(value) : ''
}
@@ -0,0 +1,132 @@
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
export const ALARM_TYPES = ['metric', 'composite'] as const
export const ALARM_STATISTICS = ['Average', 'Sum', 'Minimum', 'Maximum', 'SampleCount'] as const
export const ALARM_COMPARISONS = [
'GreaterThanThreshold',
'GreaterThanOrEqualToThreshold',
'LessThanThreshold',
'LessThanOrEqualToThreshold',
] as const
export const ALARM_PERIODS = ['60', '300', '900', '3600'] as const
export const ALARM_STATES = ['OK', 'ALARM', 'INSUFFICIENT_DATA'] as const
/** CloudWatch's alarm name rule: 1-255 characters, no control characters. */
export const ALARM_NAME_PATTERN = '^[^\\x00-\\x1f\\x7f]{1,255}$'
const alarmColumns: TableColumnSchema[] = [
{name: 'name', label: 'Alarm'},
{name: 'status', label: 'State', format: 'badge'},
{name: 'alarmType', label: 'Type', path: 'metadata.alarmType'},
{name: 'condition', label: 'Condition', path: 'metadata.condition', format: 'code', emptyText: '—'},
{name: 'stateUpdatedAt', label: 'State changed', path: 'metadata.stateUpdatedAt', format: 'relative'},
{name: 'actionsEnabled', label: 'Actions', path: 'metadata.actionsEnabled', format: 'boolean'},
]
const alarmFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
const metricOnly = {field: 'alarmType', values: ['metric']}
export function awsMonitoringSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'monitoring',
displayName: 'Amazon CloudWatch alarms',
fields: [
{
name: 'alarmType',
label: 'Alarm type',
type: 'select',
required: true,
defaultValue: 'metric',
options: [
{label: 'Metric alarm (a threshold on one metric)', value: 'metric'},
{label: 'Composite alarm (a rule over other alarms)', value: 'composite'},
],
},
{
name: 'name',
label: 'Alarm name',
type: 'text',
required: true,
validation: {pattern: ALARM_NAME_PATTERN, message: 'Use up to 255 characters, without control characters'},
},
{name: 'description', label: 'Description', type: 'text', required: false},
{name: 'namespace', label: 'Namespace', type: 'text', required: false, requiredWhen: {field: 'alarmType', equals: 'metric'}, visibleWhen: metricOnly, description: 'For example AWS/Lambda, AWS/SQS or your own, such as MyApp.'},
{name: 'metricName', label: 'Metric name', type: 'text', required: false, requiredWhen: {field: 'alarmType', equals: 'metric'}, visibleWhen: metricOnly, description: 'For example Errors or Duration.'},
{
name: 'dimensions',
label: 'Dimensions',
type: 'textarea',
required: false,
visibleWhen: metricOnly,
description: 'One Name=Value per line, for example FunctionName=checkout. Leave empty for the metric across all dimensions.',
},
{
name: 'statistic',
label: 'Statistic',
type: 'select',
required: false,
defaultValue: 'Average',
visibleWhen: metricOnly,
options: ALARM_STATISTICS.map((value) => ({label: value, value})),
},
{
name: 'period',
label: 'Period',
type: 'select',
required: false,
defaultValue: '300',
visibleWhen: metricOnly,
options: [{label: '1 minute', value: '60'}, {label: '5 minutes', value: '300'}, {label: '15 minutes', value: '900'}, {label: '1 hour', value: '3600'}],
},
{
name: 'comparison',
label: 'Condition',
type: 'select',
required: false,
defaultValue: 'GreaterThanThreshold',
visibleWhen: metricOnly,
options: [
{label: 'Greater than the threshold', value: 'GreaterThanThreshold'},
{label: 'Greater than or equal to the threshold', value: 'GreaterThanOrEqualToThreshold'},
{label: 'Less than the threshold', value: 'LessThanThreshold'},
{label: 'Less than or equal to the threshold', value: 'LessThanOrEqualToThreshold'},
],
},
{name: 'threshold', label: 'Threshold', type: 'text', required: false, requiredWhen: {field: 'alarmType', equals: 'metric'}, visibleWhen: metricOnly, validation: {pattern: '^-?\\d+(\\.\\d+)?$', message: 'Use a number'}},
{name: 'evaluationPeriods', label: 'Periods to breach', type: 'text', required: false, defaultValue: '1', visibleWhen: metricOnly, description: 'How many periods in a row must breach before the alarm fires.', validation: {pattern: '^[1-9]\\d{0,2}$', message: 'Use a whole number from 1'}},
{
name: 'alarmRule',
label: 'Rule',
type: 'textarea',
required: false,
requiredWhen: {field: 'alarmType', equals: 'composite'},
visibleWhen: {field: 'alarmType', values: ['composite']},
description: 'For example ALARM("high-errors") OR ALARM("slow-checkout"). AND, OR, NOT and parentheses work; OK() and INSUFFICIENT_DATA() too.',
},
{name: 'topicArn', label: 'Notify SNS topic', type: 'text', required: false, description: 'Optional. An SNS topic ARN told when the alarm fires and when it recovers.'},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List alarms', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create alarm', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete alarm', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect alarm', enabled: true, status: 'available', runtimeRequired: true},
],
monitoringActions: [
{name: 'listMetrics', label: 'Browse metrics', enabled: true, status: 'available', runtimeRequired: true},
{name: 'getMetricData', label: 'Chart a metric', enabled: true, status: 'available', runtimeRequired: true},
{name: 'alarmHistory', label: 'Show alarm history', enabled: false, status: 'blocked', reason: "TheCloud's runtime does not implement DescribeAlarmHistory yet.", runtimeRequired: true},
{name: 'setAlarmState', label: 'Set an alarm state to test its actions', enabled: true, status: 'available', runtimeRequired: true},
{name: 'setAlarmActions', label: 'Turn alarm actions on or off', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: alarmFilters,
columns: alarmColumns,
}
}
export function monitoringSchemaFor(cloud: CloudProvider): ServiceSchema | null {
return cloud === 'aws' ? awsMonitoringSchema() : null
}
@@ -281,6 +281,13 @@ export const SERVICE_CATALOG = {
group: 'Integration',
order: 20,
},
monitoring: {
displayName: 'Monitoring',
displayNameByCloud: {aws: 'CloudWatch'},
iconKey: 'monitoring',
group: 'Observability',
order: 5,
},
logs: {
displayName: 'Logs',
displayNameByCloud: {aws: 'CloudWatch Logs'},
+43
View File
@@ -181,6 +181,7 @@ export type UserPoolActionName =
| 'describeClient'
| 'updateClientOAuth'
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
export interface CapabilitySchema<TAction extends string> {
@@ -226,6 +227,7 @@ export interface ServiceSchema {
kubernetesActions?: CapabilitySchema<KubernetesActionName>[]
userPoolActions?: CapabilitySchema<UserPoolActionName>[]
distributionActions?: CapabilitySchema<DistributionActionName>[]
monitoringActions?: CapabilitySchema<MonitoringActionName>[]
// Child levels advertise separately from the resource level because a
// store can be readable at the leaf and writable at the collection —
// CloudWatch Logs creates streams but cannot delete an event.
@@ -457,6 +459,42 @@ export interface UserPoolSignInTestInput {
}
/** Decoded claims only: the console shows what a sign-in yields without handing out tokens. */
export interface MetricDimension {
name: string
value: string
}
export interface MetricSummary {
namespace: string
metricName: string
dimensions: MetricDimension[]
}
export interface MetricSeriesQuery {
namespace: string
metricName: string
dimensions?: MetricDimension[]
statistic?: string
periodSeconds?: number
/** How far back from now, in minutes. */
rangeMinutes?: number
}
export interface MetricSeries {
label: string
statistic: string
periodSeconds: number
points: {timestamp: string; value: number}[]
}
export type AlarmStateValue = 'OK' | 'ALARM' | 'INSUFFICIENT_DATA'
export interface AlarmHistoryItem {
timestamp: string | null
type: string
summary: string
}
export interface DistributionInvalidation {
id: string
status: string
@@ -1006,6 +1044,11 @@ export interface CloudServiceAdapter {
testUserPoolSignIn?(poolId: string, input: UserPoolSignInTestInput): Promise<UserPoolSignInTestResult>
describeUserPoolClient?(poolId: string, clientId: string): Promise<UserPoolClientSettings>
setDistributionEnabled?(distributionId: string, enabled: boolean): Promise<CloudResource>
listMetrics?(query: {namespace?: string; search?: string}): Promise<MetricSummary[]>
getMetricSeries?(query: MetricSeriesQuery): Promise<MetricSeries>
describeAlarmHistory?(alarmName: string): Promise<AlarmHistoryItem[]>
setAlarmState?(alarmName: string, state: AlarmStateValue, reason: string): Promise<void>
setAlarmActionsEnabled?(alarmName: string, enabled: boolean): Promise<void>
createDistributionInvalidation?(distributionId: string, paths: string[]): Promise<DistributionInvalidation>
updateUserPoolClientOAuth?(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings>
/** Clears the provider's locally captured email inbox, if it exposes one. */
+19 -1
View File
@@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test'
import {createCloudAdapterRegistry} from './cloudProxy'
import {checkChildCapabilities} from './cloud-spi/childCapabilities'
import {isServiceType} from './cloud-spi/serviceCatalog'
import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, DistributionActionName, KubernetesActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, DistributionActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
/**
* Guards the schema-to-implementation contract for every registered adapter.
@@ -47,6 +47,14 @@ const METHOD_FOR_KUBERNETES_ACTION: Record<KubernetesActionName, keyof CloudServ
deleteFargateProfile: 'deleteKubernetesFargateProfile',
}
const METHOD_FOR_MONITORING_ACTION: Record<MonitoringActionName, keyof CloudServiceAdapter> = {
listMetrics: 'listMetrics',
getMetricData: 'getMetricSeries',
alarmHistory: 'describeAlarmHistory',
setAlarmState: 'setAlarmState',
setAlarmActions: 'setAlarmActionsEnabled',
}
const METHOD_FOR_DISTRIBUTION_ACTION: Record<DistributionActionName, keyof CloudServiceAdapter> = {
setEnabled: 'setDistributionEnabled',
createInvalidation: 'createDistributionInvalidation',
@@ -147,6 +155,15 @@ describe('registered adapters honour their schema', () => {
).toBe('function')
}
const monitoringClaims = (adapter.schema().capabilities?.monitoringActions ?? []).filter((capability) => capability.status === 'available')
for (const capability of monitoringClaims) {
const method = METHOD_FOR_MONITORING_ACTION[capability.name]
expect(
typeof adapter[method],
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
).toBe('function')
}
const distributionClaims = (adapter.schema().capabilities?.distributionActions ?? []).filter((capability) => capability.status === 'available')
for (const capability of distributionClaims) {
const method = METHOD_FOR_DISTRIBUTION_ACTION[capability.name]
@@ -169,6 +186,7 @@ describe('registered adapters honour their schema', () => {
...(adapter.schema().capabilities?.kubernetesActions ?? []),
...(adapter.schema().capabilities?.userPoolActions ?? []),
...(adapter.schema().capabilities?.distributionActions ?? []),
...(adapter.schema().capabilities?.monitoringActions ?? []),
...(adapter.schema().capabilities?.collectionActions ?? []),
...(adapter.schema().capabilities?.itemActions ?? []),
]
+2
View File
@@ -50,6 +50,7 @@ import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter'
import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter'
import {AwsDnsAdapter} from './adapter-aws/AwsDnsAdapter'
import {AwsCdnAdapter} from './adapter-aws/AwsCdnAdapter'
import {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter'
import {awsClientsForAccount, resolveAccountId} from './aws'
import {createEc2Service} from './services/ec2'
import {createEksService} from './services/eks'
@@ -102,6 +103,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
new AwsUserPoolsAdapter(clients.cognito),
new AwsDnsAdapter(clients.route53),
new AwsCdnAdapter(clients.cloudfront, clients.s3),
new AwsMonitoringAdapter(clients.cloudwatch),
new AzureStorageAdapter(),
new AzureServiceBusAdapter(),
new AzureDatabaseAdapter(),
+28
View File
@@ -18,6 +18,7 @@ import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-s
import {awsSesEmailSchema} from '../cloud-spi/emailSchema'
import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema'
import {awsCdnSchema} from '../cloud-spi/cdnSchema'
import {awsMonitoringSchema} from '../cloud-spi/monitoringSchema'
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
import {awsNotificationsSchema} from '../cloud-spi/notificationsSchema'
@@ -770,6 +771,33 @@ describe('cloud schema routes', () => {
expect(calls).toEqual([['us-east-1_x', 'c1', input]])
})
test('routes CloudWatch metrics, history, alarm state and actions to the adapter', async () => {
const calls: unknown[] = []
const app = appWithRoutes([mockAdapter('aws', {
service: 'monitoring',
schema: awsMonitoringSchema,
listMetrics: async (query) => { calls.push(['metrics', query]); return [{namespace: 'App', metricName: 'Errors', dimensions: []}] },
getMetricSeries: async (query) => { calls.push(['series', query.metricName, query.rangeMinutes]); return {label: 'Errors', statistic: 'Sum', periodSeconds: 60, points: []} },
describeAlarmHistory: async (name) => [{timestamp: null, type: 'StateUpdate', summary: `${name} changed`}],
setAlarmState: async (name, state, reason) => { calls.push(['state', name, state, reason]) },
setAlarmActionsEnabled: async (name, enabled) => { calls.push(['actions', name, enabled]) },
})])
const json = (method: string, body: unknown) => ({method, headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
const base = '/api/clouds/aws/services/monitoring'
expect(await (await app.request(`${base}/metrics?namespace=App`)).json()).toEqual([{namespace: 'App', metricName: 'Errors', dimensions: []}])
expect((await (await app.request(`${base}/metric-data`, json('POST', {namespace: 'App', metricName: 'Errors', rangeMinutes: 60}))).json()).label).toBe('Errors')
expect((await (await app.request(`${base}/resources/high-errors/history`)).json())[0].summary).toBe('high-errors changed')
expect((await app.request(`${base}/resources/high-errors/state`, json('PUT', {state: 'ALARM', reason: 'test'}))).status).toBe(200)
expect((await app.request(`${base}/resources/high-errors/actions`, json('PUT', {enabled: false}))).status).toBe(200)
expect(calls).toEqual([
['metrics', {namespace: 'App', search: undefined}],
['series', 'Errors', 60],
['state', 'high-errors', 'ALARM', 'test'],
['actions', 'high-errors', false],
])
})
test('enables a distribution and invalidates its paths through the CloudFront adapter', async () => {
const calls: unknown[] = []
const app = appWithRoutes([mockAdapter('aws', {
+44
View File
@@ -1,6 +1,7 @@
import {Hono} from 'hono'
import type {Context} from 'hono'
import type {
AlarmStateValue,
CloudProvider,
CloudServiceType,
CreateDatabaseSnapshotInput,
@@ -461,6 +462,49 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
return withRuntime(c, async () => c.json(await svc(c).updateUserPoolClientOAuth(cloud, c.req.param('id'), c.req.param('clientId'), await c.req.json())))
})
app.get('/:cloud/services/monitoring/metrics', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => c.json(await svc(c).listMetrics(cloud, {namespace: c.req.query('namespace') || undefined, search: c.req.query('search') || undefined})))
})
app.post('/:cloud/services/monitoring/metric-data', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => c.json(await svc(c).getMetricSeries(cloud, await c.req.json())))
})
app.get('/:cloud/services/monitoring/resources/:id/history', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => c.json(await svc(c).describeAlarmHistory(cloud, c.req.param('id'))))
})
app.put('/:cloud/services/monitoring/resources/:id/state', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => {
const body = await c.req.json<{state?: AlarmStateValue; reason?: string}>()
await svc(c).setAlarmState(cloud, c.req.param('id'), body.state as AlarmStateValue, body.reason ?? '')
return c.json({ok: true})
})
})
app.put('/:cloud/services/monitoring/resources/:id/actions', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => {
const body = await c.req.json<{enabled?: boolean}>()
await svc(c).setAlarmActionsEnabled(cloud, c.req.param('id'), body.enabled as boolean)
return c.json({ok: true})
})
})
app.put('/:cloud/services/cdn/resources/:id/enabled', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
@@ -83,6 +83,11 @@ import type {
UserPoolSignInTestResult,
UserPoolClientSettings,
DistributionInvalidation,
AlarmHistoryItem,
AlarmStateValue,
MetricSeries,
MetricSeriesQuery,
MetricSummary,
UpdateUserPoolClientOAuthInput,
UserPoolUserAction,
} from '../cloud-spi/types'
@@ -834,6 +839,36 @@ export class CloudProxyService {
return adapter.testUserPoolSignIn(poolId, input)
}
async listMetrics(cloud: CloudProvider, query: {namespace?: string; search?: string}): Promise<MetricSummary[]> {
const adapter = this.requireAdapter(cloud, 'monitoring')
if (!adapter.listMetrics) throw new NotSupportedError(`Metrics are not supported for ${cloud}/monitoring`)
return adapter.listMetrics(query)
}
async getMetricSeries(cloud: CloudProvider, query: MetricSeriesQuery): Promise<MetricSeries> {
const adapter = this.requireAdapter(cloud, 'monitoring')
if (!adapter.getMetricSeries) throw new NotSupportedError(`Metric data is not supported for ${cloud}/monitoring`)
return adapter.getMetricSeries(query)
}
async describeAlarmHistory(cloud: CloudProvider, alarmName: string): Promise<AlarmHistoryItem[]> {
const adapter = this.requireAdapter(cloud, 'monitoring')
if (!adapter.describeAlarmHistory) throw new NotSupportedError(`Alarm history is not supported for ${cloud}/monitoring`)
return adapter.describeAlarmHistory(alarmName)
}
async setAlarmState(cloud: CloudProvider, alarmName: string, state: AlarmStateValue, reason: string): Promise<void> {
const adapter = this.requireAdapter(cloud, 'monitoring')
if (!adapter.setAlarmState) throw new NotSupportedError(`Setting alarm states is not supported for ${cloud}/monitoring`)
await adapter.setAlarmState(alarmName, state, reason)
}
async setAlarmActionsEnabled(cloud: CloudProvider, alarmName: string, enabled: boolean): Promise<void> {
const adapter = this.requireAdapter(cloud, 'monitoring')
if (!adapter.setAlarmActionsEnabled) throw new NotSupportedError(`Alarm actions are not supported for ${cloud}/monitoring`)
await adapter.setAlarmActionsEnabled(alarmName, enabled)
}
async setDistributionEnabled(cloud: CloudProvider, distributionId: string, enabled: boolean): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, 'cdn')
if (!adapter.setDistributionEnabled) throw new NotSupportedError(`Enabling distributions is not supported for ${cloud}/cdn`)