rubix 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/LICENSE +20 -0
- data/README.rdoc +262 -0
- data/VERSION +1 -0
- data/bin/zabbix_api +60 -0
- data/bin/zabbix_pipe +77 -0
- data/lib/rubix.rb +42 -0
- data/lib/rubix/connection.rb +111 -0
- data/lib/rubix/examples/es_monitor.rb +130 -0
- data/lib/rubix/examples/hbase_monitor.rb +87 -0
- data/lib/rubix/examples/mongo_monitor.rb +125 -0
- data/lib/rubix/log.rb +70 -0
- data/lib/rubix/model.rb +56 -0
- data/lib/rubix/models/application.rb +76 -0
- data/lib/rubix/models/host.rb +127 -0
- data/lib/rubix/models/host_group.rb +74 -0
- data/lib/rubix/models/item.rb +122 -0
- data/lib/rubix/models/template.rb +81 -0
- data/lib/rubix/monitor.rb +167 -0
- data/lib/rubix/monitors/chef_monitor.rb +82 -0
- data/lib/rubix/monitors/cluster_monitor.rb +84 -0
- data/lib/rubix/response.rb +124 -0
- data/lib/rubix/sender.rb +301 -0
- data/spec/rubix/connection_spec.rb +43 -0
- data/spec/rubix/models/host_group_spec.rb +56 -0
- data/spec/rubix/monitor_spec.rb +81 -0
- data/spec/rubix/monitors/chef_monitor_spec.rb +11 -0
- data/spec/rubix/monitors/cluster_monitor_spec.rb +11 -0
- data/spec/rubix/response_spec.rb +35 -0
- data/spec/rubix/sender_spec.rb +9 -0
- data/spec/spec_helper.rb +14 -0
- data/spec/support/response_helper.rb +17 -0
- metadata +140 -0
data/lib/rubix/sender.rb
ADDED
@@ -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
|
+
|