rubix 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+