Skip to content

Commit 371f1a4

Browse files
committed
feat: adding composite alarm type
Adding composite alarm type, using existing topic in alarmsactions, adding tests
1 parent 3882d63 commit 371f1a4

File tree

6 files changed

+338
-8
lines changed

6 files changed

+338
-8
lines changed

.husky/.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
_
1+
_

jest.config.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
module.exports = {
2+
roots: ['<rootDir>/src'],
3+
testRegex: '(/_test_/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?|ts)$',
4+
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
5+
testPathIgnorePatterns: ['node_modules'],
6+
testTimeout: 10000,
7+
testEnvironment: 'node',
8+
};

package.json

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"lodash.upperfirst": "^4.3.1"
2727
},
2828
"devDependencies": {
29+
"@types/jest": "^27.3.1",
2930
"@commitlint/config-conventional": "^11.0.0",
3031
"commitlint": "^11.0.0",
3132
"eslint": "^7.19.0",
@@ -44,10 +45,5 @@
4445
},
4546
"engines": {
4647
"node": ">=8.10.0"
47-
},
48-
"jest": {
49-
"testMatch": [
50-
"<rootDir>/src/**/*.test.js"
51-
]
5248
}
5349
}

src/index.js

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,101 @@ class AlertsPlugin {
568568
this.addCfResources(cf);
569569
}
570570

571+
_getCfResourceAndName(prefix, definitionName) {
572+
const service = this.serverless.service;
573+
const provider = service.provider;
574+
const stage = this.options.stage;
575+
const region = this.options.region || provider.region;
576+
const capitalDefinitionName = upperFirst(definitionName);
577+
return {
578+
cfResource: `Alerts${prefix}${capitalDefinitionName}`,
579+
cfName: `${service.service}-${stage}-${region}-${capitalDefinitionName}`,
580+
};
581+
}
582+
583+
_getFilteredResourcesAsArray(resourcesKeys) {
584+
const resources =
585+
this.serverless.service.provider.compiledCloudFormationTemplate.Resources;
586+
return Object.keys(resources)
587+
.filter(
588+
(resource) =>
589+
!resourcesKeys ||
590+
resourcesKeys.length === 0 ||
591+
resourcesKeys.indexOf(resource) !== -1
592+
)
593+
.map((key) => resources[key]);
594+
}
595+
596+
_resolveAlarmRules(definition) {
597+
const alarmsToInclude = this._getFilteredResourcesAsArray(
598+
definition.alarmsToInclude
599+
)
600+
.filter(
601+
(resource) =>
602+
resource.Type === 'AWS::CloudWatch::Alarm' &&
603+
resource.Properties.enabled !== false
604+
)
605+
.map((resource) => resource.Properties.AlarmName);
606+
607+
const alarmRule = alarmsToInclude
608+
.map((alarmToInclude) => `ALARM(${alarmToInclude})`)
609+
.join(' OR ');
610+
611+
return alarmRule;
612+
}
613+
614+
_resolveAlarmActions(definition) {
615+
if (!definition.alarmsActions?.length){
616+
return [];
617+
}
618+
619+
const resourcesObj =
620+
this.serverless.service.provider.compiledCloudFormationTemplate.Resources;
621+
const resourcesCfNames = Object.keys(resourcesObj)
622+
623+
const topicsToInclude = []
624+
for (const alarmAction of definition.alarmsActions) {
625+
if (resourcesCfNames.indexOf(alarmAction) !== -1) {
626+
const resource = resourcesObj[alarmAction]
627+
if (resource.Type === 'AWS::SNS::Topic' && resource.Properties.enabled !== false) {
628+
topicsToInclude.push({ Ref: alarmAction })
629+
}
630+
}
631+
}
632+
return topicsToInclude;
633+
}
634+
635+
compileCompositeAlarms(config, definitions, alertTopics) {
636+
Object.keys(definitions).forEach((definitionName) => {
637+
const definition = definitions[definitionName];
638+
if (definition.type === 'composite' && definition.enabled !== false) {
639+
const alarmRule = this._resolveAlarmRules(definition);
640+
// only create the composite alarm if there is at least one alarm to include
641+
if (alarmRule !== '') {
642+
const cf = {};
643+
const { cfResource, cfName } = this._getCfResourceAndName(
644+
'Composite',
645+
definitionName
646+
);
647+
const alarmActions = this._resolveAlarmActions(definition);
648+
const compositeAlarm = {
649+
Type: 'AWS::CloudWatch::CompositeAlarm',
650+
Properties: {
651+
AlarmName: cfName,
652+
AlarmDescription: definition.description,
653+
ActionsEnabled: definition.actionsEnabled,
654+
AlarmRule: this._resolveAlarmRules(definition),
655+
AlarmActions: alarmActions,
656+
},
657+
};
658+
659+
cf[cfResource] = compositeAlarm;
660+
this.addCfResources(cf);
661+
}
662+
}
663+
});
664+
}
665+
571666
compile() {
572667
const config = this.getConfig();
573668
if (!config) {
@@ -587,6 +682,8 @@ class AlertsPlugin {
587682

588683
this.compileAlarms(config, definitions, alertTopics);
589684

685+
this.compileCompositeAlarms(config, definitions, alertTopics);
686+
590687
if (config.dashboards) {
591688
this.compileDashboards(config.dashboards);
592689
}

src/index.test.js

Lines changed: 184 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ const Plugin = require('./index');
55

66
const testServicePath = path.join(__dirname, '.tmp');
77

8-
const pluginFactory = (alarmsConfig, s) => {
8+
const pluginFactory = (alarmsConfig, s, functionsToUse) => {
99
const stage = s || 'dev';
10-
const functions = {
10+
const functions = functionsToUse || {
1111
foo: {
1212
name: 'foo',
1313
},
@@ -47,6 +47,7 @@ const pluginFactory = (alarmsConfig, s) => {
4747
};
4848
return new Plugin(serverless, {
4949
stage,
50+
region: 'local-test',
5051
});
5152
};
5253

@@ -1629,4 +1630,185 @@ describe('#index', () => {
16291630
});
16301631
});
16311632
});
1633+
1634+
describe('#compileCompositeAlarms', () => {
1635+
const config = {
1636+
definitions: {
1637+
successRateDrop: {
1638+
type: 'successRate',
1639+
nameTemplate: '$[functionName]-successRateDrop',
1640+
period: 300, //5min
1641+
evaluationPeriods: 3,
1642+
datapointsToAlarm: 3,
1643+
threshold: 99,
1644+
comparisonOperator: 'LessThanThreshold',
1645+
treatMissingData: 'breaching',
1646+
},
1647+
compositeSuccessAlarm: {
1648+
type: 'composite',
1649+
description: 'A compile success alarm',
1650+
actionsEnabled: true,
1651+
alarmsToInclude: [
1652+
'FooSuccessRateDropAlarm'
1653+
],
1654+
alarmsActions: [
1655+
'AwsAlertsPagingTopic'
1656+
]
1657+
},
1658+
},
1659+
alarms: [
1660+
'successRateDrop'
1661+
],
1662+
topics: {
1663+
paging: {
1664+
topic: 'api-paging-alarm-topic-test',
1665+
notifications:[
1666+
{
1667+
protocol: 'email',
1668+
endpoint: 'test@email.com',
1669+
}
1670+
]
1671+
}
1672+
},
1673+
};
1674+
1675+
const functions = {
1676+
foo: {
1677+
name: 'foo',
1678+
},
1679+
foo1: {
1680+
name: 'foo1',
1681+
},
1682+
}
1683+
1684+
it('should compile only foo function alarms into composite', () => {
1685+
const plugin = pluginFactory(config, 'dev', functions);
1686+
1687+
plugin.compile();
1688+
1689+
const resources = plugin.serverless.service.provider.compiledCloudFormationTemplate.Resources
1690+
expect(Object.keys(resources)).toHaveLength(4)
1691+
expect(
1692+
plugin.serverless.service.provider.compiledCloudFormationTemplate
1693+
.Resources
1694+
).toMatchObject({
1695+
FooSuccessRateDropAlarm: {
1696+
Type: 'AWS::CloudWatch::Alarm',
1697+
},
1698+
Foo1SuccessRateDropAlarm: {
1699+
Type: 'AWS::CloudWatch::Alarm',
1700+
},
1701+
AwsAlertsPagingTopic: {
1702+
Type: 'AWS::SNS::Topic',
1703+
},
1704+
AlertsCompositeCompositeSuccessAlarm: {
1705+
Type: 'AWS::CloudWatch::CompositeAlarm',
1706+
Properties: expect.objectContaining({
1707+
AlarmRule: 'ALARM(fooservice-dev-foo-successRateDrop)',
1708+
AlarmActions: [{ Ref: 'AwsAlertsPagingTopic' }],
1709+
}),
1710+
},
1711+
});
1712+
});
1713+
1714+
it('should compile all function alarms into composite', () => {
1715+
const innerConfig = {
1716+
...config,
1717+
definitions: {
1718+
...config.definitions,
1719+
compositeSuccessAlarm: {
1720+
...config.definitions.compositeSuccessAlarm,
1721+
alarmsToInclude: undefined,
1722+
}
1723+
}
1724+
}
1725+
const plugin = pluginFactory(innerConfig, 'dev', functions);
1726+
1727+
plugin.compile();
1728+
1729+
const resources = plugin.serverless.service.provider.compiledCloudFormationTemplate.Resources
1730+
expect(Object.keys(resources)).toHaveLength(4)
1731+
expect(
1732+
plugin.serverless.service.provider.compiledCloudFormationTemplate
1733+
.Resources
1734+
).toMatchObject({
1735+
FooSuccessRateDropAlarm: {
1736+
Type: 'AWS::CloudWatch::Alarm',
1737+
},
1738+
Foo1SuccessRateDropAlarm: {
1739+
Type: 'AWS::CloudWatch::Alarm',
1740+
},
1741+
AwsAlertsPagingTopic: {
1742+
Type: 'AWS::SNS::Topic',
1743+
},
1744+
AlertsCompositeCompositeSuccessAlarm: {
1745+
Type: 'AWS::CloudWatch::CompositeAlarm',
1746+
Properties: expect.objectContaining({
1747+
AlarmRule: 'ALARM(fooservice-dev-foo-successRateDrop) OR ALARM(fooservice-dev-foo1-successRateDrop)',
1748+
AlarmActions: [{ Ref: 'AwsAlertsPagingTopic' }],
1749+
}),
1750+
},
1751+
});
1752+
});
1753+
1754+
it('should skip composite alarms that are marked disabled', () => {
1755+
const innerConfig = {
1756+
...config,
1757+
definitions: {
1758+
...config.definitions,
1759+
compositeSuccessAlarm: {
1760+
...config.definitions.compositeSuccessAlarm,
1761+
enabled: false,
1762+
}
1763+
}
1764+
}
1765+
const plugin = pluginFactory(innerConfig, 'dev', functions);
1766+
1767+
plugin.compile();
1768+
1769+
const resources = plugin.serverless.service.provider.compiledCloudFormationTemplate.Resources
1770+
expect(Object.keys(resources)).toHaveLength(3)
1771+
expect(
1772+
plugin.serverless.service.provider.compiledCloudFormationTemplate
1773+
.Resources
1774+
).toMatchObject({
1775+
FooSuccessRateDropAlarm: {
1776+
Type: 'AWS::CloudWatch::Alarm',
1777+
},
1778+
Foo1SuccessRateDropAlarm: {
1779+
Type: 'AWS::CloudWatch::Alarm',
1780+
},
1781+
AwsAlertsPagingTopic: {
1782+
Type: 'AWS::SNS::Topic',
1783+
},
1784+
});
1785+
});
1786+
1787+
it('should skip composite alarms if all alarms are disabled', () => {
1788+
const innerConfig = {
1789+
...config,
1790+
definitions: {
1791+
...config.definitions,
1792+
successRateDrop: {
1793+
...config.definitions.successRateDrop,
1794+
enabled: false,
1795+
}
1796+
}
1797+
}
1798+
const plugin = pluginFactory(innerConfig, 'dev', functions);
1799+
1800+
plugin.compile();
1801+
1802+
const resources = plugin.serverless.service.provider.compiledCloudFormationTemplate.Resources
1803+
expect(Object.keys(resources)).toHaveLength(1)
1804+
expect(
1805+
plugin.serverless.service.provider.compiledCloudFormationTemplate
1806+
.Resources
1807+
).toMatchObject({
1808+
AwsAlertsPagingTopic: {
1809+
Type: 'AWS::SNS::Topic',
1810+
},
1811+
});
1812+
});
1813+
});
16321814
});

0 commit comments

Comments
 (0)