cfn_monitor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,186 @@
1
+ require "cfndsl"
2
+ require 'fileutils'
3
+ require 'tempfile'
4
+ require 'yaml'
5
+
6
+ require "cfn_monitor/utils"
7
+
8
+ module CfnMonitor
9
+ class Generate
10
+
11
+ def self.run(options)
12
+
13
+ if !options['application']
14
+ raise "No application specified"
15
+ end
16
+
17
+ application = options['application']
18
+
19
+ template_path = File.join(File.dirname(__FILE__),'../config/templates.yml')
20
+ config_path = File.join(File.dirname(__FILE__),'../config/config.yml')
21
+ # Load global config files
22
+ global_templates_config = YAML.load(File.read(template_path))
23
+ config = YAML.load(File.read(config_path))
24
+
25
+ custom_alarms_config_file = "#{application}/alarms.yml"
26
+ custom_templates_config_file = "#{application}/templates.yml"
27
+ output_path = "output/#{application}"
28
+ upload_path = "cloudformation/monitoring/#{application}"
29
+
30
+ # Load custom config files
31
+ if File.file?(custom_alarms_config_file)
32
+ custom_alarms_config = YAML.load(File.read(custom_alarms_config_file))
33
+ else
34
+ puts "Failed to load #{custom_alarms_config_file}"
35
+ exit 1
36
+ end
37
+
38
+ # Merge custom template configs over global template configs
39
+ if File.file?(custom_templates_config_file)
40
+ custom_templates_config = YAML.load(File.read(custom_templates_config_file))
41
+ templates = CommonHelper.deep_merge(global_templates_config, custom_templates_config)
42
+ else
43
+ templates = global_templates_config
44
+ end
45
+
46
+ # Create an array of alarms based on the templates associated with each resource
47
+ alarms = []
48
+ resources = custom_alarms_config['resources']
49
+ metrics = custom_alarms_config['metrics']
50
+ hosts = custom_alarms_config['hosts']
51
+ hosts ||= {}
52
+ services = custom_alarms_config['services']
53
+ services ||= {}
54
+ endpoints = custom_alarms_config['endpoints']
55
+ endpoints ||= {}
56
+ rme = { resources: resources, metrics: metrics, endpoints: endpoints, hosts: hosts, services: services }
57
+ source_bucket = custom_alarms_config['source_bucket']
58
+
59
+ rme.each do | k,v |
60
+ if !v.nil?
61
+ v.each do | resource,attributes |
62
+ # set environments to 'all' by default
63
+ environments = ['all']
64
+ # Support config hashs for additional parameters
65
+ params = {}
66
+ if attributes.kind_of?(Hash)
67
+ attributes.each do | a,b |
68
+ environments = b if a == 'environments'
69
+ # Convert strings to arrays for consistency
70
+ if !environments.kind_of?(Array) then environments = environments.split end
71
+ params[a] = b if !['template','environments'].member? a
72
+ end
73
+ templatesEnabled = attributes['template']
74
+ else
75
+ templatesEnabled = attributes
76
+ end
77
+ # Convert strings to arrays for looping
78
+ if !templatesEnabled.kind_of?(Array) then templatesEnabled = templatesEnabled.split end
79
+ templatesEnabled.each do | templateEnabled |
80
+ if !templates['templates'][templateEnabled].nil?
81
+ # If a template is provided, inherit that template
82
+ if !templates['templates'][templateEnabled]['template'].nil?
83
+ template_from = Marshal.load( Marshal.dump(templates['templates'][templates['templates'][templateEnabled]['template']]) )
84
+ template_to = templates['templates'][templateEnabled].without('template')
85
+ template_merged = CommonHelper.deep_merge(template_from, template_to)
86
+ templates['templates'][templateEnabled] = template_merged
87
+ end
88
+ templates['templates'][templateEnabled].each do | alarm,parameters |
89
+ resourceParams = parameters.clone
90
+ # Override template params if overrides provided
91
+ params.each do | x,y |
92
+ resourceParams[x] = y
93
+ end
94
+ if k == :hosts
95
+ resourceParams['cmds'].each do |cmd|
96
+ hostParams = resourceParams.clone
97
+ hostParams['cmd'] = cmd
98
+ # Construct alarm object per cmd
99
+ alarms << {
100
+ resource: resource,
101
+ type: k[0...-1],
102
+ template: templateEnabled,
103
+ alarm: alarm,
104
+ parameters: hostParams,
105
+ environments: environments
106
+ }
107
+ end
108
+ else
109
+ # Construct alarm object
110
+ alarms << {
111
+ resource: resource,
112
+ type: k[0...-1],
113
+ template: templateEnabled,
114
+ alarm: alarm,
115
+ parameters: resourceParams,
116
+ environments: environments
117
+ }
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
125
+
126
+ # Create temp alarms file for CfnDsl
127
+ temp_file = Tempfile.new(["alarms-",'.yml'])
128
+ temp_file_path = temp_file.path
129
+ temp_file.write({'alarms' => alarms}.to_yaml)
130
+ temp_file.rewind
131
+
132
+ # Split resources for mulitple templates to avoid CloudFormation template resource limits
133
+ split = []
134
+ template_envs = ['production']
135
+ alarms.each_with_index do |alarm,index|
136
+ split[index/config['resource_limit']] ||= {}
137
+ split[index/config['resource_limit']]['alarms'] ||= []
138
+ split[index/config['resource_limit']]['alarms'] << alarm
139
+ template_envs |= get_alarm_envs(alarm[:parameters])
140
+ end
141
+
142
+ # Create temp files for split resources for CfnDsl input
143
+ temp_files=[]
144
+ temp_file_paths=[]
145
+ (alarms.count/config['resource_limit'].to_f).ceil.times do | i |
146
+ temp_files[i] = Tempfile.new(["alarms-#{i}-",'.yml'])
147
+ temp_file_paths[i] = temp_files[i].path
148
+ temp_files[i].write(split[i].to_yaml)
149
+ temp_files[i].rewind
150
+ end
151
+
152
+ write_cfdndsl_template(temp_file_path, temp_file_paths, custom_alarms_config_file, source_bucket, template_envs, output_path, upload_path)
153
+
154
+ end
155
+
156
+ def self.write_cfdndsl_template(alarms_config,configs,custom_alarms_config_file,source_bucket,template_envs,output_path,upload_path)
157
+ template_path = File.expand_path("../../templates", __FILE__)
158
+ FileUtils::mkdir_p output_path
159
+ configs.each_with_index do |config,index|
160
+ File.open("#{output_path}/resources#{index}.json", 'w') { |file|
161
+ file.write(JSON.pretty_generate( CfnDsl.eval_file_with_extras("#{template_path}/resources.rb",[[:yaml, config],[:raw, "template_number=#{index}"],[:raw, "source_bucket='#{source_bucket}'"],[:raw, "upload_path='#{upload_path}'"]],STDOUT)))}
162
+ File.open("#{output_path}/alarms#{index}.json", 'w') { |file|
163
+ file.write(JSON.pretty_generate( CfnDsl.eval_file_with_extras("#{template_path}/alarms.rb",[[:yaml, config],[:raw, "template_number=#{index}"],[:raw, "template_envs=#{template_envs}"]],STDOUT)))}
164
+ end
165
+ File.open("#{output_path}/endpoints.json", 'w') { |file|
166
+ file.write(JSON.pretty_generate( CfnDsl.eval_file_with_extras("#{template_path}/endpoints.rb",[[:yaml, alarms_config],[:raw, "template_envs=#{template_envs}"]],STDOUT)))}
167
+ File.open("#{output_path}/hosts.json", 'w') { |file|
168
+ file.write(JSON.pretty_generate( CfnDsl.eval_file_with_extras("#{template_path}/hosts.rb",[[:yaml, alarms_config],[:raw, "template_envs=#{template_envs}"]],STDOUT)))}
169
+ File.open("#{output_path}/services.json", 'w') { |file|
170
+ file.write(JSON.pretty_generate( CfnDsl.eval_file_with_extras("#{template_path}/services.rb",[[:yaml, alarms_config],[:raw, "template_envs=#{template_envs}"]],STDOUT)))}
171
+ File.open("#{output_path}/master.json", 'w') { |file|
172
+ file.write(JSON.pretty_generate( CfnDsl.eval_file_with_extras("#{template_path}/master.rb",[[:yaml, custom_alarms_config_file],[:raw, "templateCount=#{configs.count}"],[:raw, "template_envs=#{template_envs}"],[:raw, "upload_path='#{upload_path}'"]],STDOUT)))}
173
+ end
174
+
175
+ def self.get_alarm_envs(params)
176
+ envs = []
177
+ params.each do | key,value |
178
+ if key.include? '.'
179
+ envs << key.split('.').last
180
+ end
181
+ end
182
+ return envs
183
+ end
184
+
185
+ end
186
+ end
@@ -0,0 +1,135 @@
1
+ require 'aws-sdk-cloudformation'
2
+ require 'aws-sdk-elasticloadbalancingv2'
3
+
4
+ module CfnMonitor
5
+ class Query
6
+
7
+ def self.run(options)
8
+
9
+ if !options['application'] || !options['stack']
10
+ raise "No application specified"
11
+ end
12
+
13
+ if !options['stack']
14
+ raise "No stack specified"
15
+ end
16
+
17
+ config_path = File.join(File.dirname(__FILE__),'../config/config.yml')
18
+ # Load global config files
19
+ config = YAML.load(File.read(config_path))
20
+
21
+ custom_alarms_config_file = "#{options['application']}/alarms.yml"
22
+
23
+ # Load custom config files
24
+ custom_alarms_config = YAML.load(File.read(custom_alarms_config_file)) if File.file?(custom_alarms_config_file)
25
+ custom_alarms_config ||= {}
26
+ custom_alarms_config['resources'] ||= {}
27
+
28
+ puts "-----------------------------------------------"
29
+ puts "stack: #{options['stack']}"
30
+ puts "application: #{options['application']}"
31
+ puts "-----------------------------------------------"
32
+ puts "Searching Stacks for Monitorable Resources"
33
+ puts "-----------------------------------------------"
34
+
35
+ cfClient = Aws::CloudFormation::Client.new()
36
+ elbClient = Aws::ElasticLoadBalancingV2::Client.new()
37
+
38
+ stackResourceQuery = query_stacks(config,cfClient,elbClient,options['stack'])
39
+ stackResourceCount = stackResourceQuery[:stackResourceCount]
40
+ stackResources = stackResourceQuery[:stackResources]
41
+
42
+ configResourceCount = custom_alarms_config['resources'].keys.count
43
+ configResources = []
44
+ keyUpdates = []
45
+
46
+ stackResources[:template].each do | k,v |
47
+ if stackResources[:physical_resource_id].key? k.partition('/').last
48
+ keyUpdates << k
49
+ end
50
+ end
51
+
52
+ keyUpdates.each do | k |
53
+ stackResources[:template]["#{k.partition('/').first}/#{stackResources[:physical_resource_id][k.partition('/').last]}"] = stackResources[:template].delete(k)
54
+ end
55
+
56
+ stackResources[:template].each do | k,v |
57
+ if !custom_alarms_config['resources'].any? {|x, y| x == k}
58
+ configResources.push("#{k}: #{v}")
59
+ end
60
+ end
61
+
62
+ puts "-----------------------------------------------"
63
+ puts "Monitorable Resources (with default templates)"
64
+ puts "-----------------------------------------------"
65
+ stackResources[:template].each do | k,v |
66
+ puts "#{k}: #{v}"
67
+ end
68
+ puts "-----------------------------------------------"
69
+ if configResourceCount < stackResourceCount
70
+ puts "Missing resources (with default templates)"
71
+ puts "-----------------------------------------------"
72
+ configResources.each do | r |
73
+ puts r
74
+ end
75
+ puts "-----------------------------------------------"
76
+ end
77
+ puts "Monitorable resources in #{options['stack']} stack: #{stackResourceCount}"
78
+ puts "Resources in #{options['application']} alarms config: #{configResourceCount}"
79
+ if stackResourceCount > 0
80
+ puts "Coverage: #{100-(configResources.count*100/stackResourceCount)}%"
81
+ end
82
+ puts "-----------------------------------------------"
83
+
84
+ end
85
+
86
+ def self.query_stacks (config,cfClient,elbClient,stack,stackResources={template:{},physical_resource_id:{}},location='')
87
+ stackResourceCount = 0
88
+ stackResourceCountLocal = 0
89
+ begin
90
+ resp = cfClient.list_stack_resources({
91
+ stack_name: stack
92
+ })
93
+ rescue Aws::CloudFormation::Errors::ServiceError => e
94
+ puts "Error: #{e}"
95
+ exit 1
96
+ end
97
+
98
+ resp.stack_resource_summaries.each do | resource |
99
+ if resource['resource_type'] == 'AWS::CloudFormation::Stack'
100
+ query = query_stacks(config,cfClient,elbClient,resource['physical_resource_id'],stackResources,"#{location}.#{resource['logical_resource_id']}")
101
+ stackResourceCount += query[:stackResourceCount]
102
+ end
103
+ if config['resource_defaults'].key? resource['resource_type']
104
+ if resource['resource_type'] == 'AWS::ElasticLoadBalancingV2::TargetGroup'
105
+ begin
106
+ tg = elbClient.describe_target_groups({
107
+ target_group_arns: [ resource['physical_resource_id'] ]
108
+ })
109
+ rescue Aws::ElasticLoadBalancingV2::Errors::ServiceError => e
110
+ puts "Error: #{e}"
111
+ exit 1
112
+ end
113
+ stackResources[:template]["#{location[1..-1]}.#{resource['logical_resource_id']}/#{tg['target_groups'][0]['load_balancer_arns'][0]}"] = config['resource_defaults'][resource['resource_type']]
114
+ else
115
+ stackResources[:template]["#{location[1..-1]}.#{resource['logical_resource_id']}"] = config['resource_defaults'][resource['resource_type']]
116
+ end
117
+ stackResourceCount += 1
118
+ stackResourceCountLocal += 1
119
+ print "#{location[1..-1]}: Found #{stackResourceCount} resource#{"s" if stackResourceCount != 1}\r"
120
+ sleep 0.2
121
+ elsif resource['resource_type'] == 'AWS::ElasticLoadBalancingV2::LoadBalancer'
122
+ stackResources[:physical_resource_id][resource['physical_resource_id']] = "#{location[1..-1]}.#{resource['logical_resource_id']}"
123
+ end
124
+ end
125
+ stackResourceQuery = {
126
+ stackResourceCount: stackResourceCount,
127
+ stackResources: stackResources
128
+ }
129
+ sleep 0.2
130
+ puts "#{stack if location == ''}#{location[1..-1]}: Found #{stackResourceCountLocal} resource#{"s" if stackResourceCountLocal != 1}"
131
+ stackResourceQuery
132
+ end
133
+
134
+ end
135
+ end
@@ -0,0 +1,42 @@
1
+ module CfnMonitor
2
+ class Utils
3
+
4
+ # Merge Hash B into hash A. If any values or hashes as well, merge will be performed recursively
5
+ # Returns Hash A with updated values
6
+ def self.deep_merge(a, b)
7
+
8
+ # Loop over key/value pairs
9
+ b.each { |key, value|
10
+
11
+ # If key from B present in map A
12
+ if a.key? key
13
+
14
+ # If both are hashes call recursively
15
+ if (a[key].class == Hash and b[key].class == Hash)
16
+ a[key] = deep_merge(a[key], value)
17
+ else
18
+ # Overwrite value with value from B
19
+ a[key] = value
20
+ end
21
+ else
22
+ # Add key from B
23
+ a[key] = value
24
+ end
25
+ }
26
+
27
+ # Return hash a
28
+ return a
29
+ end
30
+
31
+ end
32
+ end
33
+
34
+ class Hash
35
+ def without(*keys)
36
+ dup.without!(*keys)
37
+ end
38
+
39
+ def without!(*keys)
40
+ reject! { |key| keys.include?(key) }
41
+ end
42
+ end
@@ -0,0 +1,3 @@
1
+ module CfnMonitor
2
+ VERSION = "0.1.0".freeze
3
+ end
@@ -0,0 +1,66 @@
1
+ require "thor"
2
+ require "cfn_monitor/version"
3
+ require "cfn_monitor/query"
4
+ require "cfn_monitor/generate"
5
+ require "cfn_monitor/deploy"
6
+
7
+ module CfnMonitor
8
+ class Commands < Thor
9
+
10
+ map %w[--version -v] => :__print_version
11
+ desc "--version, -v", "print the version"
12
+ def __print_version
13
+ puts CfnMonitor::VERSION
14
+ end
15
+
16
+ # class_option :verbose,
17
+ # aliases: :V,
18
+ # type: :boolean,
19
+ # default: false,
20
+ # lazy_default: true,
21
+ # desc: "Extra logging"
22
+ #
23
+ # class_option :region,
24
+ # group: :aws,
25
+ # aliases: :r,
26
+ # type: :string,
27
+ # desc: "AWS Region"
28
+ #
29
+ # class_option :profile,
30
+ # group: :aws,
31
+ # aliases: :p,
32
+ # type: :string,
33
+ # desc: "Profile name in AWS credentials file"
34
+
35
+ desc "generate", "Generate monitoring cloudformation templates"
36
+ long_desc <<-LONG
37
+ Generates cloudformation templates from the alarm configuration and output to the output/ directory.
38
+ LONG
39
+ method_option :application, aliases: :a, type: :string, desc: "application name"
40
+ # method_option :validate, aliases: :v, type: :boolean, default: true, desc: "validate cfn templates"
41
+ def generate
42
+ CfnMonitor::Generate.run(options)
43
+ end
44
+
45
+ desc "query", "Queries a cloudformation stack for monitorable resources"
46
+ long_desc <<-LONG
47
+ This will provide a list of resources in the correct config syntax,
48
+ including the nested stacks and the default templates for those resources.
49
+ LONG
50
+ method_option :application, aliases: :a, type: :string, desc: "application name"
51
+ method_option :stack, aliases: :s, type: :string, desc: "cfn stack name"
52
+ def query
53
+ CfnMonitor::Query.run(options)
54
+ end
55
+
56
+ desc "deploy", "Deploys gerenated cfn templates to S3 bucket"
57
+ long_desc <<-LONG
58
+ Deploys gerenated cloudformation templates to the specified S3 source_bucket
59
+ LONG
60
+ method_option :application, aliases: :a, type: :string, desc: "application name"
61
+ def deploy
62
+ CfnMonitor::Deploy.run(options)
63
+ end
64
+
65
+ end
66
+ end
@@ -0,0 +1,14 @@
1
+ resource_limit: 50
2
+ resource_defaults:
3
+ AWS::ElasticLoadBalancing::LoadBalancer: ElasticLoadBalancer
4
+ AWS::ElasticLoadBalancingV2::TargetGroup: ApplicationELBTargetGroup
5
+ AWS::AutoScaling::AutoScalingGroup: AutoScalingGroup
6
+ AWS::RDS::DBInstance: RDSInstance
7
+ AWS::RDS::DBCluster: DBCluster
8
+ AWS::ECS::Cluster: ECSCluster
9
+ AWS::ElastiCache::ReplicationGroup: ElastiCacheReplicationGroup
10
+ AWS::EFS::FileSystem: ElasticFileSystem
11
+ AWS::EC2::Instance: Ec2Instance
12
+ AWS::Redshift::Cluster: RedshiftCluster
13
+ AWS::DynamoDB::Table: DynamoDBTable
14
+ AWS::ApiGateway::RestApi: ApiGateway