feat(api): add CloudWatch alarms and metric charts to the cloud explorer
CI / checks (pull_request) Successful in 27m29s
CI / checks (pull_request) Successful in 27m29s
This commit is contained in:
@@ -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'},
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 ?? []),
|
||||
]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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`)
|
||||
|
||||
Reference in New Issue
Block a user