bfire 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (37) hide show
  1. data/LICENSE +0 -0
  2. data/README.md +90 -0
  3. data/bin/bfire +120 -0
  4. data/examples/benchmark.rb +18 -0
  5. data/examples/dag.rb +26 -0
  6. data/examples/elasticity.rb +105 -0
  7. data/examples/ibbt.rb +125 -0
  8. data/examples/mine.rb +40 -0
  9. data/examples/modules/apache2/manifests/init.pp +44 -0
  10. data/examples/modules/app/files/app/app.rb +29 -0
  11. data/examples/modules/app/files/app/config.ru +2 -0
  12. data/examples/modules/app/files/app.phtml +4 -0
  13. data/examples/modules/app/manifests/init.pp +19 -0
  14. data/examples/modules/common/manifests/init.pp +8 -0
  15. data/examples/modules/haproxy/files/default +4 -0
  16. data/examples/modules/haproxy/files/haproxy.rsyslog.conf +2 -0
  17. data/examples/modules/haproxy/manifests/init.pp +21 -0
  18. data/examples/modules/mysql/manifests/init.pp +40 -0
  19. data/examples/modules/rsyslog/files/rsyslog.conf +116 -0
  20. data/examples/modules/rsyslog/manifests/init.pp +15 -0
  21. data/examples/modules/sinatra/manifests/init.pp +9 -0
  22. data/examples/modules/web/files/monitor/app.rb +55 -0
  23. data/examples/modules/web/files/monitor/config.ru +2 -0
  24. data/examples/modules/web/files/monitor/haproxy.cfg.erb +50 -0
  25. data/examples/modules/web/manifests/init.pp +26 -0
  26. data/examples/simple.rb +58 -0
  27. data/lib/bfire/aggregator/zabbix.rb +55 -0
  28. data/lib/bfire/engine.rb +546 -0
  29. data/lib/bfire/group.rb +241 -0
  30. data/lib/bfire/metric.rb +36 -0
  31. data/lib/bfire/provider/puppet.rb +58 -0
  32. data/lib/bfire/pub_sub/publisher.rb +40 -0
  33. data/lib/bfire/rule.rb +110 -0
  34. data/lib/bfire/template.rb +142 -0
  35. data/lib/bfire/version.rb +3 -0
  36. data/lib/bfire.rb +10 -0
  37. metadata +241 -0
@@ -0,0 +1,241 @@
1
+ require 'bfire/template'
2
+ require 'bfire/rule'
3
+
4
+ module Bfire
5
+ class Group
6
+ include Enumerable
7
+ include PubSub::Publisher
8
+
9
+ attr_reader :engine
10
+ attr_reader :name
11
+ attr_reader :dependencies
12
+ attr_reader :templates
13
+ # A free-form text tag to add to every compute name of this group.
14
+ attr_reader :tag
15
+
16
+ def initialize(engine, name, options = {})
17
+ @engine = engine
18
+ @name = name
19
+ @tag = options.delete(:tag)
20
+ raise Error, "Tag name can't contain two or more consecutive dashes" if @tag && @tag =~ /-{2,}/
21
+ @options = options
22
+ @listeners = {}
23
+ @dependencies = []
24
+
25
+ @templates = []
26
+ @default_template = Template.new(self, :default)
27
+ @current_template = @default_template
28
+
29
+ raise Error, "Group name can only contain [a-zA-Z0-9] characters" if name !~ /[a-z0-9]+/i
30
+
31
+ on(:error) {|group| Thread.current.group.list.each{|t|
32
+ t[:ko] = true
33
+ t.kill
34
+ }
35
+ }
36
+ on(:ready) {|group|
37
+ group.engine.logger.info "#{group.banner}All VMs are now READY: #{computes.map{|vm|
38
+ [vm['name'], (vm['nic'] || []).map{|n| n['ip']}.inspect].join("=")
39
+ }.join("; ")}"
40
+ }
41
+ end
42
+
43
+ def launch_initial_resources
44
+ merge_templates!
45
+ engine.logger.debug "#{banner}Merged templates=#{templates.inspect}"
46
+ check!
47
+ if rule.launch_initial_resources
48
+ trigger :launched
49
+ true
50
+ else
51
+ trigger :error
52
+ false
53
+ end
54
+ rescue Exception => e
55
+ engine.logger.error "#{banner}#{e.class.name}: #{e.message}"
56
+ engine.logger.debug e.backtrace.join("; ")
57
+ trigger :error
58
+ end
59
+
60
+ def monitor
61
+ rule.manage(computes)
62
+ rule.monitor
63
+ rescue Exception => e
64
+ engine.logger.error "#{banner}#{e.class.name}: #{e.message}"
65
+ engine.logger.debug e.backtrace.join("; ")
66
+ trigger :error
67
+ end
68
+
69
+ def provision!(vms)
70
+ return true if provider.nil?
71
+ engine.logger.info "#{banner}Provisioning..."
72
+ vms.all?{|vm|
73
+ provisioned = false
74
+ ip = vm['nic'][0]['ip']
75
+ engine.ssh(ip, 'root') {|s|
76
+ provisioned = unless provider.install(s)
77
+ engine.logger.error "Failed to install provider on #{vm.inspect} (IP=#{ip})."
78
+ false
79
+ else
80
+ result = provider.run(s) do |stream|
81
+ engine.logger.info "#{banner}[#{ip}] #{stream}"
82
+ end
83
+ end
84
+ }
85
+ provisioned
86
+ }
87
+ end
88
+
89
+ # Delegates every unknown method to the current Template, except #conf.
90
+ def method_missing(method, *args, &block)
91
+ if method == :conf
92
+ engine.send(method, *args, &block)
93
+ else
94
+ @current_template.send(method, *args, &block)
95
+ end
96
+ end
97
+
98
+ # ======================
99
+ # = Group-only methods =
100
+ # ======================
101
+
102
+ def rule
103
+ @rule ||= Rule.new(self, :initial => 1, :range => 1..1)
104
+ end
105
+
106
+ # Defines the scaling rule for this group
107
+ def scale(range, options = {})
108
+ @rule = Rule.new(self, options.merge(:range => range))
109
+ end
110
+
111
+ def at(location, &block)
112
+ t = template(location)
113
+ @current_template = t
114
+ instance_eval(&block) unless block.nil?
115
+ @current_template = @default_template
116
+ end
117
+
118
+ def depends_on(group_name, &block)
119
+ @dependencies.push [group_name, block]
120
+ end
121
+
122
+ # Define the provider to use to provision the compute resources
123
+ # (Puppet, Chef...).
124
+ # If <tt>selected_provider</tt> is nil, returns the current provider.
125
+ def provider(selected_provider = nil, options = {})
126
+ return @provider if selected_provider.nil?
127
+ options[:modules] = engine.path_to(options[:modules]) if options[:modules]
128
+ @provider = Provider::Puppet.new(options)
129
+ end
130
+
131
+
132
+ # ===========
133
+ # = Helpers =
134
+ # ===========
135
+
136
+ def banner
137
+ "[#{name}] "
138
+ end
139
+
140
+ # Iterates over the collection of compute resources.
141
+ # Required for the Enumerable module.
142
+ def each(*args, &block)
143
+ computes.each(*args, &block)
144
+ end
145
+
146
+ def computes
147
+ templates.map{|t| t.instances}.flatten
148
+ end
149
+
150
+ # Return the first <tt>how_many</tt> compute resources of the group.
151
+ def take(how_many = :all)
152
+ case how_many
153
+ when :all
154
+ computes
155
+ when :first
156
+ computes[0]
157
+ else
158
+ raise ArgumentError, "You must pass :all, :first, or a Fixnum" unless how_many.kind_of?(Fixnum)
159
+ computes.take(how_many)
160
+ end
161
+ end
162
+
163
+ def inspect
164
+ s = "#<#{self.class.name}:0x#{object_id.to_s(16)}"
165
+ s << " #{banner}" if banner
166
+ s << "VMs: "
167
+ s << computes.map{|vm|
168
+ [vm['name'].inspect, (vm['nic'] || []).map{|n|
169
+ n['ip']
170
+ }.inspect].join("=")
171
+ }.join("; ")
172
+ s << ">"
173
+ end
174
+
175
+ def reload
176
+ each(&:reload)
177
+ end
178
+
179
+ def ssh_accessible?(vms)
180
+ vms.all?{|compute|
181
+ begin
182
+ ip = compute['nic'][0]['ip']
183
+ Timeout.timeout(30) do
184
+ engine.ssh(ip, 'root', :log => false) {|s|
185
+ s.exec!("hostname")
186
+ }
187
+ end
188
+ true
189
+ rescue Exception => e
190
+ engine.logger.debug "#{banner}Can't SSH yet to #{compute.signature} at IP=#{ip.inspect}. Reason: #{e.class.name}, #{e.message}. Will retry later."
191
+ false
192
+ end
193
+ }
194
+ end
195
+
196
+ def template(location)
197
+ t = @templates.find{|t| t.name == location}
198
+ if t.nil?
199
+ t = Template.new(
200
+ self,
201
+ location
202
+ )
203
+ @templates.push(t)
204
+ end
205
+ t
206
+ end
207
+
208
+ def check!
209
+ check_templates!
210
+ if provider && !provider.valid?
211
+ raise Error, "#{banner}#{provider.errors.map(&:inspect).join(", ")}"
212
+ end
213
+ end
214
+
215
+ def merge_templates!
216
+ default = @default_template
217
+ if engine.conf[:authorized_keys]
218
+ default.context :authorized_keys => File.read(
219
+ File.expand_path(engine.conf[:authorized_keys])
220
+ )
221
+ end
222
+ if @templates.empty?
223
+ @templates.push template(:any)
224
+ end
225
+ templates.each{|t|
226
+ t.merge_defaults!(default).resolve!
227
+ }
228
+ end # def merge_templates!
229
+
230
+ protected
231
+
232
+ def check_templates!
233
+ errors = []
234
+ templates.each do |t|
235
+ t.valid? || errors.push({t.name => t.errors})
236
+ end
237
+ raise Error, "#{banner}#{errors.map(&:inspect).join(", ")}" unless errors.empty?
238
+ end # def check_templates!
239
+
240
+ end
241
+ end
@@ -0,0 +1,36 @@
1
+ class Array
2
+ def sum
3
+ inject(:+)
4
+ end
5
+
6
+ def avg
7
+ sum.to_f / size
8
+ end
9
+
10
+ def median
11
+ sorted = sort
12
+ (sorted[size/2] + sorted[(size+1)/2]) / 2
13
+ end
14
+ end
15
+
16
+ module Bfire
17
+ class Metric
18
+
19
+ def initialize(name, results, opts = {})
20
+ @name = name
21
+ @results = results
22
+ @opts = opts
23
+ end
24
+
25
+ def values
26
+ @results.map{|r|
27
+ case @opts[:type]
28
+ when :numeric
29
+ r['value'].to_f
30
+ else
31
+ r['value']
32
+ end
33
+ }.reverse
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,58 @@
1
+ module Bfire
2
+ module Provider
3
+ class Puppet
4
+ attr_reader :classes
5
+ attr_reader :modules
6
+ attr_reader :options
7
+ attr_reader :errors
8
+
9
+ def initialize(opts = {})
10
+ @classes = opts.delete(:classes) || opts.delete("classes")
11
+ @modules = opts.delete(:modules) || opts.delete("modules")
12
+ @options = opts
13
+ @errors = []
14
+ end
15
+
16
+ def install(ssh_session)
17
+ res = ssh_session.exec!("apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install curl puppet -y")
18
+ res = ssh_session.exec!("which puppet")
19
+ !res.nil? && !res.empty?
20
+ end
21
+
22
+ def run(ssh_session)
23
+ ssh_session.exec!("rm -rf /tmp/puppet && mkdir -p /tmp/puppet")
24
+ ssh_session.scp.upload!(
25
+ StringIO.new(manifest("vm")),
26
+ "/tmp/puppet/manifest.pp"
27
+ )
28
+ ssh_session.sftp.upload!(modules, "/tmp/puppet/modules")
29
+ ssh_session.exec!(
30
+ "puppet --modulepath /tmp/puppet/modules /tmp/puppet/manifest.pp"
31
+ ) do |ch, stream, data|
32
+ yield "[#{stream.to_s.upcase}] #{data.chomp}"
33
+ end
34
+ true
35
+ end
36
+
37
+ def manifest(name)
38
+ content = <<MANIFEST
39
+ class #{name} {
40
+ #{classes.map{|klass| "include #{klass}"}.join("\n")}
41
+ }
42
+
43
+ include #{name}
44
+ MANIFEST
45
+ end
46
+
47
+ def valid?
48
+ @errors = []
49
+ if modules.nil?
50
+ @errors.push("You must pass a :modules option to `provider`")
51
+ elsif !File.directory?(modules)
52
+ @errors.push("#{modules} is not a valid directory")
53
+ end
54
+ @errors.empty?
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,40 @@
1
+ module Bfire
2
+ module PubSub
3
+ module Publisher
4
+ # Notify all group listeners when event <tt>event</tt> occurs.
5
+ def trigger(event)
6
+ triggered_events.push(event)
7
+ engine.logger.info "#{banner}Triggering #{event.inspect} event..."
8
+ (hooks[event] || []).each{|block|
9
+ if block.arity == 1
10
+ block.call(self)
11
+ else
12
+ engine.instance_eval(&block)
13
+ end
14
+ }
15
+ end
16
+
17
+ # Defines a procedure (hook) to launch when event <tt>event</tt> occurs.
18
+ def on(event, &block)
19
+ hooks[event.to_sym] ||= []
20
+ hooks[event.to_sym] << block
21
+ end
22
+
23
+ def error?
24
+ triggered_events.include?(:error)
25
+ end
26
+
27
+ def hooks
28
+ @hooks ||= {}
29
+ end
30
+
31
+ def triggered_events
32
+ @triggered_events ||= []
33
+ end
34
+
35
+ def self.included(mod)
36
+ end
37
+
38
+ end
39
+ end
40
+ end
data/lib/bfire/rule.rb ADDED
@@ -0,0 +1,110 @@
1
+ module Bfire
2
+ class Rule
3
+ attr_reader :group
4
+ attr_reader :opts
5
+
6
+ include PubSub::Publisher
7
+
8
+ def initialize(group, opts = {})
9
+ @group = group
10
+ @opts = {:period => 5*60, :initial => 1, :range => 1..1}.merge(opts)
11
+ end
12
+
13
+ # we only support round-robin placement for now
14
+ def monitor
15
+ loop do
16
+ sleep opts[:period]
17
+ group.engine.logger.info "#{group.banner}Monitoring group elasticity rule..."
18
+ # this is blocking because we don't want the rule to be triggered
19
+ # too many times.
20
+ if scale_up?
21
+ group.engine.logger.info "#{group.banner}Scaling up!"
22
+ manage(scale(:up))
23
+ elsif scale_down?
24
+ group.engine.logger.info "#{group.banner}Scaling down!"
25
+ manage(scale(:down))
26
+ else
27
+ group.engine.logger.info "#{group.banner}..."
28
+ end
29
+ end
30
+ end
31
+
32
+ def launch_initial_resources
33
+ scale(:up, opts[:initial])
34
+ end
35
+
36
+ def scale(up_or_down, count = 1)
37
+ new_computes = []
38
+ count.times do |i|
39
+ sorted_templates = group.templates.sort_by{|t| t.instances.length}
40
+ if up_or_down == :down
41
+ vm_to_delete = sorted_templates.last.instances[0]
42
+ if vm_to_delete.nil?
43
+ group.engine.logger.warn "#{group.banner}No resource to delete!"
44
+ else
45
+ group.engine.logger.info "#{group.banner}Removing compute #{vm_to_delete.signature}..."
46
+ if vm_to_delete.delete
47
+ sorted_templates.last.instances.delete vm_to_delete
48
+ group.trigger :scaled_down
49
+ end
50
+ end
51
+ else
52
+ template = sorted_templates.first
53
+ computes = group.engine.launch_compute(template)
54
+ template.instances.push(*computes)
55
+ new_computes.push(*computes)
56
+ end
57
+ end
58
+ new_computes
59
+ end
60
+
61
+ def manage(vms)
62
+ return true if vms.empty?
63
+ group.engine.logger.info "#{group.banner}Monitoring VMs... IPs: #{vms.map{|vm| [vm['name'], (vm['nic'] || []).map{|n| n['ip']}.inspect].join("=")}.join("; ")}."
64
+ vms.each(&:reload)
65
+ if failed = vms.find{|compute| compute['state'] == 'FAILED'}
66
+ group.engine.logger.warn "#{group.banner}Compute #{failed.signature} is in a FAILED state."
67
+ if group.triggered_events.include?(:ready)
68
+ group.trigger :error
69
+ else
70
+ group.trigger :scale_error
71
+ end
72
+ elsif vms.all?{|compute| compute['state'] == 'ACTIVE'}
73
+ group.engine.logger.info "#{group.banner}All compute resources are ACTIVE"
74
+ if group.ssh_accessible?(vms)
75
+ group.engine.logger.info "#{group.banner}All compute resources are SSH-able"
76
+ provisioned = group.provision!(vms)
77
+ if group.triggered_events.include?(:ready)
78
+ if provisioned
79
+ group.trigger :scaled_up
80
+ else
81
+ group.trigger :scale_error
82
+ end
83
+ else
84
+ if provisioned
85
+ group.trigger :ready
86
+ else
87
+ group.trigger :error
88
+ end
89
+ end
90
+ monitor
91
+ else
92
+ sleep 20
93
+ manage(vms)
94
+ end
95
+ else
96
+ group.engine.logger.info "#{group.banner}Some compute resources are still PENDING"
97
+ sleep 10
98
+ manage(vms)
99
+ end
100
+ end
101
+
102
+ def scale_up?
103
+ opts[:up] && group.computes.length < opts[:range].end && opts[:up].call(group.engine)
104
+ end
105
+
106
+ def scale_down?
107
+ opts[:down] && group.computes.length > opts[:range].begin && opts[:down].call(group.engine)
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,142 @@
1
+ module Bfire
2
+ class Template
3
+ # Return the list of nics defined.
4
+ attr_reader :nics
5
+ # Return the list of disks defined.
6
+ attr_reader :disks
7
+ # Return the template name (i.e. the location name).
8
+ attr_reader :name
9
+ # Return the properties defined for this template (instance_type, etc.).
10
+ attr_reader :properties
11
+ # Return the list of metrics defined.
12
+ attr_reader :metrics
13
+ attr_reader :context
14
+ attr_reader :instances
15
+
16
+ # Return an Array of error messages in case this template is not valid.
17
+ attr_reader :errors
18
+ # Return the group this template belongs to.
19
+ attr_reader :group
20
+
21
+ def initialize(group, location_name = nil)
22
+ @group = group
23
+ @location_name = location_name
24
+ @name = location_name
25
+ @nics = []
26
+ @disks = []
27
+ @errors = []
28
+ @metrics = []
29
+ @properties = {}
30
+ @context = {}
31
+ @instances = []
32
+ end
33
+
34
+ def location
35
+ @location ||= if @location_name == :default
36
+ # noop
37
+ else
38
+ group.engine.fetch_location(@location_name)
39
+ end
40
+ end
41
+
42
+ def context(opts = {})
43
+ if opts.empty?
44
+ @context
45
+ else
46
+ @context.merge!(opts)
47
+ end
48
+ end
49
+
50
+ # Define the instance type to use.
51
+ def instance_type(instance_type)
52
+ @properties[:instance_type] = instance_type.to_s
53
+ end
54
+
55
+ # Define the image to deploy on the compute resources.
56
+ def deploy(storage, options = {})
57
+ props = options.merge(
58
+ :storage => storage
59
+ )
60
+ if @disks.empty?
61
+ @disks.push props
62
+ else
63
+ @disks[0] = props
64
+ end
65
+ end
66
+
67
+ def connect_to(network, options = {})
68
+ @nics.push options.merge(
69
+ :network => network
70
+ )
71
+ end
72
+
73
+ # Merge this template with another one.
74
+ # nics, disks, and metrics will be added, while other properties will be
75
+ # merged.
76
+ def merge_defaults!(template)
77
+ @properties = template.properties.merge(@properties)
78
+ @context = template.context.merge(@context)
79
+ template.nics.each do |nic|
80
+ @nics.unshift nic.clone
81
+ end
82
+ template.disks.each do |disk|
83
+ @disks.unshift disk.clone
84
+ end
85
+ template.metrics.each do |metric|
86
+ @metrics.unshift metric.clone
87
+ end
88
+ self
89
+ end
90
+
91
+ # Returns true if valid, false otherwise
92
+ def valid?
93
+ @errors = []
94
+ @errors.push("You must specify an instance_type") unless properties[:instance_type]
95
+ @errors.push("You must specify at least one disk image") if @disks.empty?
96
+ @errors.push("You must specify at least one network attachment") if @nics.empty?
97
+ @errors.empty?
98
+ end
99
+
100
+ # Resolve the networks and storages required for the template to be valid.
101
+ def resolve!
102
+ nics.each{|nic|
103
+ nic[:network] = group.engine.fetch_network(
104
+ nic[:network],
105
+ location
106
+ ) || raise(Error, "Can't find network #{nic[:network].inspect} at #{location["name"].inspect}")
107
+ }
108
+ disks.each{|disk|
109
+ disk[:storage] = group.engine.fetch_storage(
110
+ disk[:storage],
111
+ location
112
+ ) || raise(Error, "Can't find storage #{disk[:storage].inspect} at #{location["name"].inspect}")
113
+ }
114
+ self
115
+ end
116
+
117
+ # Register a metric on the compute resources.
118
+ def register(metric_name, options = {})
119
+ @metrics.push options.merge(:name => metric_name)
120
+ end
121
+
122
+ # Exports the template to a ruby Hash, which conforms to what is expected
123
+ # by Restfully to submit a resource.
124
+ def to_h
125
+ h = {}
126
+ h.merge!(@properties)
127
+ h['name'] = "#{group.name}--#{name}--#{SecureRandom.hex(4)}"
128
+ h['name'] << "-#{group.tag}" if group.tag
129
+ h['nic'] = nics
130
+ h['disk'] = disks
131
+ h['location'] = location
132
+ h['context'] = @context
133
+ h['context']['metrics'] = XML::Node.new_cdata(metrics.map{|m|
134
+ "<metric>"+[m[:name], m[:command]].join(",")+"</metric>"
135
+ }.join("")) unless metrics.empty?
136
+ group.dependencies.each{|gname,block|
137
+ h['context'].merge!(block.call(group.engine.group(gname)))
138
+ }
139
+ h
140
+ end
141
+ end # class Template
142
+ end # module Bup
@@ -0,0 +1,3 @@
1
+ module Bfire
2
+ VERSION = "0.2.0"
3
+ end
data/lib/bfire.rb ADDED
@@ -0,0 +1,10 @@
1
+ require 'uuidtools'
2
+
3
+ require 'bfire/pub_sub/publisher'
4
+ require 'bfire/provider/puppet'
5
+ require 'bfire/version'
6
+ require 'bfire/engine'
7
+
8
+ module Bfire
9
+ class Error < StandardError; end
10
+ end