bfire 0.2.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.
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