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 = { 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 { 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 { 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 { 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 { await this.cloudwatch.send(new DeleteAlarmsCommand({AlarmNames: [name]})) } async listMetrics(query: {namespace?: string; search?: string} = {}): Promise { 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 { 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 { 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 { 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 { 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(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) : '' }