rubix 0.0.1

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,301 @@
1
+ require 'rubix/log'
2
+
3
+ module Rubix
4
+
5
+ class Sender
6
+
7
+ include Logs
8
+
9
+ # A Hash of options.
10
+ attr_accessor :settings
11
+
12
+ # The host the Sender will send data for.
13
+ attr_accessor :host
14
+
15
+ # The hostgroups used to create this host.
16
+ attr_accessor :host_groups
17
+
18
+ # The templates used to create this host.
19
+ attr_accessor :templates
20
+
21
+ # The applications used to create items.
22
+ attr_accessor :applications
23
+
24
+ #
25
+ # Initialization
26
+ #
27
+
28
+ def initialize settings
29
+ @settings = settings
30
+ confirm_settings
31
+ self.host = Host.new(:name => settings['host'])
32
+ @log_name = "PIPE #{host.name}"
33
+ if settings['fast']
34
+ info("Forwarding...") if settings['verbose']
35
+ else
36
+ initialize_hostgroups
37
+ initialize_templates
38
+ initialize_host
39
+ initialize_applications
40
+ info("Forwarding...") if settings['verbose'] && host.exists?
41
+ end
42
+ end
43
+
44
+ def alive?
45
+ settings['fast'] || host.exists?
46
+ end
47
+
48
+ def initialize_hostgroups
49
+ self.host_groups = settings['host_groups'].split(',').flatten.compact.map { |group_name | HostGroup.find_or_create_by_name(group_name.strip) }
50
+ end
51
+
52
+ def initialize_templates
53
+ self.templates = (settings['templates'] || '').split(',').flatten.compact.map { |template_name | Template.find_or_create_by_name(template_name.strip) }
54
+ end
55
+
56
+ def initialize_host
57
+ unless host.exists?
58
+ host.host_groups = host_groups
59
+ host.templates = templates
60
+ host.create
61
+ end
62
+ # if settings['verbose']
63
+ # puts "Forwarding data for Host '#{settings['host']}' (#{host_id}) from #{settings['pipe']} to #{settings['server']}"
64
+ # puts "Creating Items in Application '#{settings['application']}' (#{application_id}) at #{settings['api_server']} as #{settings['username']}"
65
+ # end
66
+ end
67
+
68
+ def initialize_applications
69
+ self.applications = (settings['applications'] || '').split(',').flatten.compact.map { |app_name| Application.find_or_create_by_name_and_host(app_name, host) }
70
+ end
71
+
72
+ def confirm_settings
73
+ raise ConnectionError.new("Must specify a Zabbix server to send data to.") unless settings['server']
74
+ raise Error.new("Must specify the path to a local configuraiton file") unless settings['configuration_file'] && File.file?(settings['configuration_file'])
75
+ raise ConnectionError.new("Must specify the name of a host to send data for.") unless settings['host']
76
+ raise ValidationError.new("Must define at least one host group.") if settings['host_groups'].nil? || settings['host_groups'].empty?
77
+ end
78
+
79
+ #
80
+ # Actions
81
+ #
82
+
83
+ def run
84
+ return unless alive?
85
+ case
86
+ when settings['pipe']
87
+ process_pipe
88
+ when settings.rest.size > 0
89
+ settings.rest.each do |path|
90
+ process_file(path)
91
+ end
92
+ else
93
+ process_stdin
94
+ end
95
+ exit(0)
96
+ end
97
+
98
+ # Process each line of the file at +path+.
99
+ def process_file path
100
+ f = File.new(path)
101
+ process_file_handle(f)
102
+ f.close
103
+ end
104
+
105
+ # Process each line of standard input.
106
+ def process_stdin
107
+ process_file_handle($stdin)
108
+ end
109
+
110
+ # Process each line read from the pipe.
111
+ def process_pipe
112
+ # We want to open this pipe in non-blocking read mode b/c
113
+ # otherwise this process becomes hard to kill.
114
+ f = File.new(settings['pipe'], (File::RDONLY | File::NONBLOCK))
115
+ while true
116
+ process_file_handle(f)
117
+ # In non-blocking mode, an EOFError from f.readline doesn't mean
118
+ # there's no more data to read, just that there's no more data
119
+ # right *now*. If we sleep for a bit there might be more data
120
+ # coming down the pipe.
121
+ sleep settings['pipe_read_sleep']
122
+ end
123
+ f.close
124
+ end
125
+
126
+ # Process each line of a given file handle +f+.
127
+ def process_file_handle f
128
+ begin
129
+ line = f.readline
130
+ rescue EOFError
131
+ line = nil
132
+ end
133
+ while line
134
+ process_line(line)
135
+ begin
136
+ # FIXME -- this call to File#readline blocks and doesn't let
137
+ # stuff like SIGINT (generated from Ctrl-C on a keyboard,
138
+ # say) take affect.
139
+ line = f.readline
140
+ rescue EOFError
141
+ line = nil
142
+ end
143
+ end
144
+ end
145
+
146
+ def process_line line
147
+ if looks_like_json?(line)
148
+ process_line_of_json_in_new_pipe(line)
149
+ else
150
+ process_line_of_tsv_in_this_pipe(line)
151
+ end
152
+ end
153
+
154
+ # Parse and send a single +line+ of TSV input to the Zabbix server.
155
+ # The line will be split at tabs and expects either
156
+ #
157
+ # a) two columns: an item key and a value
158
+ # b) three columns: an item key, a value, and a timestamp
159
+ #
160
+ # Unexpected input will cause an error to be logged.
161
+ def process_line_of_tsv_in_this_pipe line
162
+ parts = line.strip.split("\t")
163
+ case parts.size
164
+ when 2
165
+ timestamp = Time.now
166
+ key, value = parts
167
+ when 3
168
+ key, value = parts[0..1]
169
+ timestamp = Time.parse(parts.last)
170
+ else
171
+ error("Each line of input must be a tab separated row consisting of 2 columns (key, value) or 3 columns (timestamp, key, value)")
172
+ return
173
+ end
174
+ send(key, value, timestamp)
175
+ end
176
+
177
+ # Parse and send a single +line+ of JSON input to the Zabbix server.
178
+ # The JSON must have a key +data+ in order to be processed. The
179
+ # value of 'data' should be an Array of Hashes each with a +key+ and
180
+ # +value+.
181
+ #
182
+ # This ZabbixPipe's settings will be merged with the remainder of
183
+ # the JSON hash. This allows sending values for 'host2' to an
184
+ # instance of ZabbixPipe already set up to receive for 'host1'.
185
+ #
186
+ # This is useful for sending data for keys from multiple hosts
187
+ #
188
+ # Example of expected input:
189
+ #
190
+ # {
191
+ # 'data': [
192
+ # {'key': 'foo.bar.baz', 'value': 10},
193
+ # {'key': 'snap.crackle.pop', 'value': 8 }
194
+ # ]
195
+ # }
196
+ #
197
+ # Or when sending for another host:
198
+ #
199
+ # {
200
+ # 'hostname': 'shazaam',
201
+ # 'application': 'silly',
202
+ # 'data': [
203
+ # {'key': 'foo.bar.baz', 'value': 10},
204
+ # {'key': 'snap.crackle.pop', 'value': 8 }
205
+ # ]
206
+ # }
207
+ def process_line_of_json_in_new_pipe line
208
+ begin
209
+ json = JSON.parse(line)
210
+ rescue JSON::ParserError => e
211
+ error("Malformed JSON")
212
+ return
213
+ end
214
+
215
+ data = json.delete('data')
216
+ unless data && data.is_a?(Array)
217
+ error("A line of JSON input must a have an Array key 'data'")
218
+ return
219
+ end
220
+
221
+ if json.empty?
222
+ # If there are no other settings then the daughter will be the
223
+ # same as the parent -- so just use 'self'.
224
+ daughter_pipe = self
225
+ else
226
+ # We merge the settings from 'self' with whatever else is
227
+ # present in the line.
228
+ begin
229
+ daughter_pipe = self.class.new(settings.stringify_keys.merge(json))
230
+ return unless daughter_pipe.alive?
231
+ rescue Error => e
232
+ error(e.message)
233
+ return
234
+ end
235
+ end
236
+
237
+ data.each do |point|
238
+ key = point['key']
239
+ value = point['value']
240
+ unless key && value
241
+ warn("The elements of the 'data' Array must be Hashes with a 'key' and a 'value'")
242
+ next
243
+ end
244
+
245
+ tsv_line = [key, value].map(&:to_s).join("\t")
246
+ daughter_pipe.process_line(tsv_line)
247
+ end
248
+ end
249
+
250
+ # Does the line look like it might be JSON?
251
+ def looks_like_json? line
252
+ line =~ /^\s*\{/
253
+ end
254
+
255
+ # Send the +value+ for +key+ at the given +timestamp+ to the Zabbix
256
+ # server.
257
+ #
258
+ # If the +key+ doesn't exist for this local agent's host, it will be
259
+ # added.
260
+ def send key, value, timestamp
261
+ item = Item.new(:key => key, :host => host, :applications => applications, :value_type => Item.value_type_from_value(value))
262
+ unless settings['fast'] || item.exists?
263
+ return unless item.create
264
+ # There is a time lag of about 15-30 seconds between (successfully)
265
+ # creating an item on the Zabbix server and having the Zabbix accept
266
+ # new data for that item.
267
+ #
268
+ # If it is crucial that *every single* data point be written, dial
269
+ # up this sleep period. The first data point for a new key will put
270
+ # the wrapper to sleep for this period of time, in hopes that the
271
+ # Zabbix server will catch up and be ready to accept new data
272
+ # points.
273
+ #
274
+ # If you don't care that you're going to lose the first few data
275
+ # points you send to Zabbix, then don't worry about it.
276
+ sleep settings['create_item_sleep']
277
+ end
278
+ command = "#{settings['sender']} --config #{settings['configuration_file']} --zabbix-server #{settings['server']} --host #{settings['host']} --key #{key} --value '#{value}'"
279
+ process_zabbix_sender_output(key, `#{command}`)
280
+
281
+ # command = "zabbix_sender --config #{configuration_file} --zabbix-server #{server} --input-file - --with-timestamps"
282
+ # open(command, 'w') do |zabbix_sender|
283
+ # zabbix_sender.write([settings['host'], key, timestamp.to_i, value].map(&:to_s).join("\t"))
284
+ # zabbix_sender.close_write
285
+ # process_zabbix_sender_output(zabbix_sender.read)
286
+ # end
287
+ end
288
+
289
+ # Parse the +text+ output by +zabbix_sender+.
290
+ def process_zabbix_sender_output key, text
291
+ return unless settings['verbose']
292
+ lines = text.strip.split("\n")
293
+ return if lines.size < 1
294
+ status_line = lines.first
295
+ status_line =~ /Processed +(\d+) +Failed +(\d+) +Total +(\d+)/
296
+ processed, failed, total = $1, $2, $3
297
+ warn("Failed to write #{failed} values to key '#{key}'") if failed.to_i != 0
298
+ end
299
+
300
+ end
301
+ end
@@ -0,0 +1,43 @@
1
+ require 'spec_helper'
2
+
3
+ describe Rubix::Connection do
4
+
5
+ before do
6
+
7
+ @mock_server = mock("Net::HTTP instance")
8
+ Net::HTTP.stub!(:new).and_return(@mock_server)
9
+
10
+ @mock_response = mock("Net::HTTP::Response instance")
11
+ @mock_server.stub!(:request).and_return(@mock_response)
12
+ @mock_response.stub!(:code).and_return('200')
13
+
14
+
15
+ @good_auth_response = '{"result": "auth_token"}'
16
+ @blah_response = '{"result": "bar"}'
17
+
18
+ @mock_response.stub!(:body).and_return(@blah_response)
19
+
20
+ @connection = Rubix::Connection.new('localhost/api.php', 'username', 'password')
21
+ end
22
+
23
+ it "should attempt to authorize itself without being asked" do
24
+ @connection.should_receive(:authorize!)
25
+ @connection.request('foobar', {})
26
+ end
27
+
28
+ it "should not repeatedly authorize itself" do
29
+ @mock_response.stub!(:body).and_return(@good_auth_response, @blah_response, @blah_response)
30
+ @connection.request('foobar', {})
31
+ @connection.should_not_receive(:authorize!)
32
+ @connection.request('foobar', {})
33
+ end
34
+
35
+ it "should increment its request ID" do
36
+ @mock_response.stub!(:body).and_return(@good_auth_response, @blah_response, @blah_response)
37
+ @connection.request('foobar', {})
38
+ @connection.request('foobar', {})
39
+ @connection.request_id.should == 3 # it's the number used for the *next* request
40
+ end
41
+
42
+ end
43
+
@@ -0,0 +1,56 @@
1
+ require 'spec_helper'
2
+
3
+ describe Rubix::HostGroup do
4
+
5
+ before do
6
+ @name = 'foobar'
7
+ @id = 100
8
+ @group = Rubix::HostGroup.new(:name => @name)
9
+
10
+ @successful_get_response = mock_response([{'groupid' => @id, 'name' => @name, 'hosts' => [{'hostid' => 1}, {'hostid' => 2}]}])
11
+ @successful_create_response = mock_response({'groupids' => [@id]})
12
+ @empty_response = mock_response
13
+ end
14
+
15
+ describe 'loading' do
16
+ it "should retrieve properties for an existing host group" do
17
+ @group.should_receive(:request).with('hostgroup.get', kind_of(Hash)).and_return(@successful_get_response)
18
+ @group.exists?.should be_true
19
+ @group.name.should == @name
20
+ @group.id.should == @id
21
+ end
22
+
23
+ it "should recognize a host group does not exist" do
24
+ @group.should_receive(:request).with('hostgroup.get', kind_of(Hash)).and_return(@empty_response)
25
+ @group.exists?.should be_false
26
+ @group.name.should == @name
27
+ @group.id.should be_nil
28
+ end
29
+ end
30
+
31
+ describe 'creating' do
32
+
33
+ it "can successfully create a new host group" do
34
+ @group.should_receive(:request).with('hostgroup.create', kind_of(Array)).and_return(@successful_create_response)
35
+ @group.create
36
+ @group.exists?.should be_true
37
+ @group.id.should == @id
38
+ end
39
+
40
+ it "can handle an error" do
41
+ @group.should_receive(:request).with('hostgroup.get', kind_of(Hash)).and_return(@empty_response)
42
+ @group.should_receive(:request).with('hostgroup.create', kind_of(Array)).and_return(@empty_response)
43
+ @group.create
44
+ @group.exists?.should be_false
45
+ end
46
+
47
+ end
48
+
49
+ describe 'updating' do
50
+
51
+ end
52
+
53
+ describe 'destroying' do
54
+ end
55
+
56
+ end
@@ -0,0 +1,81 @@
1
+ require 'spec_helper'
2
+ require 'tempfile'
3
+
4
+ describe Rubix::Monitor do
5
+
6
+ before do
7
+ @measurement = '{"data":[{"value":"bar","key":"foo"}]}'
8
+
9
+ @wrapper = Class.new(Rubix::Monitor)
10
+ @wrapper.class_eval do
11
+ def measure
12
+ write do |data|
13
+ data << ['foo', 'bar']
14
+ end
15
+ end
16
+ end
17
+ end
18
+
19
+ describe 'writing to STDOUT' do
20
+
21
+ it "should be the default behavior when run with no arguments" do
22
+ ::ARGV.replace([])
23
+ $stdout.should_receive(:puts).with(@measurement)
24
+ @wrapper.run
25
+ end
26
+
27
+ it "should flush after each write" do
28
+ ::ARGV.replace([])
29
+ $stdout.stub!(:puts)
30
+ $stdout.should_receive(:flush).twice()
31
+ @wrapper.run
32
+ @wrapper.run
33
+ end
34
+ end
35
+
36
+ describe 'writing to files' do
37
+
38
+ before do
39
+ @file = Tempfile.new('monitor', '/tmp')
40
+ ::ARGV.replace([@file.path])
41
+ end
42
+
43
+ after do
44
+ FileUtils.rm(@file.path) if File.exist?(@file.path)
45
+ end
46
+
47
+ it "should create a new file if called with a path that doesn't exist" do
48
+ FileUtils.rm(@file.path) if File.exist?(@file.path)
49
+ @wrapper.run
50
+ File.read(@file.path).should include(@measurement)
51
+ end
52
+
53
+ it "should append to an existing file" do
54
+ File.open(@file.path, 'w') { |f| f.puts('old content') }
55
+ @wrapper.run
56
+ File.read(@file.path).should include(@measurement)
57
+ File.read(@file.path).should include('old content')
58
+ end
59
+ end
60
+
61
+ describe 'writing to FIFOs' do
62
+
63
+ before do
64
+ @file = Tempfile.new('monitor', '/tmp')
65
+ FileUtils.rm(@file.path) if File.exist?(@file.path)
66
+ `mkfifo #{@file.path}`
67
+ ::ARGV.replace([@file.path])
68
+ end
69
+
70
+ after do
71
+ FileUtils.rm(@file.path) if File.exist?(@file.path)
72
+ end
73
+
74
+ it "should not block or error when writing to a FIFO with no listener" do
75
+ @wrapper.run
76
+ end
77
+
78
+ end
79
+
80
+ end
81
+