327 lines
16 KiB
TypeScript
327 lines
16 KiB
TypeScript
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) : ''
|
||
}
|