solanum 0.2.0 → 1.0.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.
- data/README.md +51 -45
- data/bin/solanum +11 -53
- data/lib/solanum.rb +109 -54
- data/lib/solanum/config.rb +67 -74
- data/lib/solanum/output/print.rb +18 -0
- data/lib/solanum/output/riemann.rb +20 -0
- data/lib/solanum/schedule.rb +55 -0
- data/lib/solanum/source.rb +13 -106
- data/lib/solanum/source/certificate.rb +88 -0
- data/lib/solanum/source/cpu.rb +119 -0
- data/lib/solanum/source/diskstats.rb +118 -0
- data/lib/solanum/source/load.rb +44 -0
- data/lib/solanum/source/memory.rb +70 -0
- data/lib/solanum/source/network.rb +65 -0
- data/lib/solanum/source/uptime.rb +28 -0
- data/lib/solanum/util.rb +40 -0
- metadata +31 -16
- checksums.yaml +0 -7
- data/lib/solanum/matcher.rb +0 -70
@@ -0,0 +1,18 @@
|
|
1
|
+
class Solanum
|
2
|
+
class Output
|
3
|
+
class Print
|
4
|
+
def initialize(args)
|
5
|
+
end
|
6
|
+
|
7
|
+
def write_events(events)
|
8
|
+
events.each do |event|
|
9
|
+
puts "%-40s %5s (%s) %s" % [
|
10
|
+
event['service'], event['metric'],
|
11
|
+
event['state'].nil? ? "--" : event['state'],
|
12
|
+
event.inspect
|
13
|
+
]
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
require 'riemann/client'
|
2
|
+
|
3
|
+
class Solanum
|
4
|
+
class Output
|
5
|
+
class Riemann
|
6
|
+
|
7
|
+
def initialize(args)
|
8
|
+
@client = Riemann::Client.new(host: args[:host], port: args[:port])
|
9
|
+
end
|
10
|
+
|
11
|
+
def write_events(events)
|
12
|
+
# OPTIMIZE: batch events?
|
13
|
+
events.each do |event|
|
14
|
+
@client << event
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# Schedule management class.
|
2
|
+
class Solanum
|
3
|
+
class Schedule
|
4
|
+
|
5
|
+
def initialize
|
6
|
+
@lock = Mutex.new
|
7
|
+
@timetable = []
|
8
|
+
end
|
9
|
+
|
10
|
+
|
11
|
+
# Peek at the next scheduled entry.
|
12
|
+
def peek_next
|
13
|
+
@lock.synchronize do
|
14
|
+
@timetable.first
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
|
19
|
+
# Time to spend waiting until the next scheduled entry. Returns a number of
|
20
|
+
# seconds, or nil if the schedule is empty.
|
21
|
+
def next_wait
|
22
|
+
entry = peek_next
|
23
|
+
if entry
|
24
|
+
next_time, next_id = *entry
|
25
|
+
duration = next_time - Time.now
|
26
|
+
#puts "Next scheduled run for #{next_id} at #{next_time} in #{duration} seconds" # DEBUG
|
27
|
+
duration
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
|
32
|
+
# Try to get the next ready entry. Returns the id if it is ready and removes
|
33
|
+
# it from the scheudle, otherwise nil if no entries are ready to run.
|
34
|
+
def pop_ready!
|
35
|
+
@lock.synchronize do
|
36
|
+
if @timetable.first && Time.now >= @timetable.first[0]
|
37
|
+
entry = @timetable.shift
|
38
|
+
entry[1]
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
|
44
|
+
# Schedule the given id for later running. Returns the scheduled entry.
|
45
|
+
def insert!(time, id)
|
46
|
+
entry = [time, id]
|
47
|
+
@lock.synchronize do
|
48
|
+
@timetable << entry
|
49
|
+
@timetable.sort_by! {|e| e[0] }
|
50
|
+
end
|
51
|
+
entry
|
52
|
+
end
|
53
|
+
|
54
|
+
end
|
55
|
+
end
|
data/lib/solanum/source.rb
CHANGED
@@ -1,116 +1,23 @@
|
|
1
|
-
|
1
|
+
class Solanum
|
2
|
+
class Source
|
3
|
+
attr_reader :type, :period, :attributes
|
2
4
|
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
#
|
8
|
-
# Author:: Greg Look
|
9
|
-
class Solanum::Source
|
10
|
-
attr_reader :config, :matchers
|
11
|
-
|
12
|
-
# Creates a new Source
|
13
|
-
def initialize(config)
|
14
|
-
@config = config
|
15
|
-
@matchers = []
|
16
|
-
end
|
17
|
-
|
18
|
-
# Collect input and process it with the defined matchers to produce some
|
19
|
-
# output measurements. The current set of metrics collected in this cycle
|
20
|
-
# will be passed to the measure function.
|
21
|
-
def collect(current_metrics)
|
22
|
-
input = read_input!
|
23
|
-
metrics = {}
|
24
|
-
|
25
|
-
unless input.nil?
|
26
|
-
@matchers.each do |matcher|
|
27
|
-
measurements = matcher.call(input)
|
28
|
-
metrics.merge!(measurements) if measurements
|
29
|
-
end
|
30
|
-
end
|
31
|
-
|
32
|
-
metrics
|
5
|
+
def initialize(opts)
|
6
|
+
@type = opts['type']
|
7
|
+
@period = (opts['period'] || 10).to_i
|
8
|
+
@attributes = opts['attributes']
|
33
9
|
end
|
34
10
|
|
35
|
-
private
|
36
11
|
|
37
|
-
|
38
|
-
|
39
|
-
nil
|
12
|
+
def collect!
|
13
|
+
raise "Not Yet Implemented"
|
40
14
|
end
|
41
15
|
|
42
|
-
# Declares a matcher for a single line of input.
|
43
|
-
def match(pattern, options={}, &block)
|
44
|
-
raise "pattern must be provided" if pattern.nil?
|
45
|
-
|
46
|
-
commands = 0
|
47
|
-
commands += 1 if options[:record]
|
48
|
-
commands += 1 if block_given?
|
49
|
-
raise "Must specify :record or provide a block to execute" if commands == 0
|
50
|
-
raise "Only one of :record or a block should be provided" if commands > 1
|
51
16
|
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
value = value.send(options[:cast]) if options[:cast]
|
56
|
-
value *= options[:scale] if options[:scale]
|
57
|
-
{options[:record] => value}
|
58
|
-
end
|
59
|
-
end
|
60
|
-
|
61
|
-
@matchers << Solanum::Matcher::LinePattern.new(block, pattern)
|
62
|
-
end
|
63
|
-
|
64
|
-
# Declares a matcher for JSON input.
|
65
|
-
def json(&block)
|
66
|
-
@matchers << Solanum::Matcher::JSONReader.new(block)
|
17
|
+
def next_run(from=Time.now)
|
18
|
+
jitter = 0.95 + 0.10*rand
|
19
|
+
from + jitter*@period
|
67
20
|
end
|
68
21
|
|
69
|
-
|
70
|
-
|
71
|
-
### SOURCE TYPES ###
|
72
|
-
|
73
|
-
public
|
74
|
-
|
75
|
-
class File < Solanum::Source
|
76
|
-
def read_input!
|
77
|
-
# Check that file exists and is readable.
|
78
|
-
raise "File does not exist: #{@config}" unless ::File.exists? @config
|
79
|
-
raise "File is not readable: #{@config}" unless ::File.readable? @config
|
80
|
-
|
81
|
-
# Read lines from the file.
|
82
|
-
::File.read(@config)
|
83
|
-
end
|
84
|
-
end
|
85
|
-
|
86
|
-
class Command < Solanum::Source
|
87
|
-
def read_input!
|
88
|
-
# Locate absolute command path.
|
89
|
-
command, args = @config.split(/\s/, 2)
|
90
|
-
abs_command =
|
91
|
-
if ::File.executable? command
|
92
|
-
command
|
93
|
-
else
|
94
|
-
%x{which #{command} 2> /dev/null}.chomp
|
95
|
-
end
|
96
|
-
|
97
|
-
# Check that command exists and is executable.
|
98
|
-
raise "Command #{command} not found" unless ::File.exist? abs_command
|
99
|
-
raise "Command #{abs_command} not executable" unless ::File.executable? abs_command
|
100
|
-
|
101
|
-
# Run command for output.
|
102
|
-
input = %x{#{abs_command} #{args}}
|
103
|
-
raise "Error executing command: #{abs_command} #{args}" unless $?.success?
|
104
|
-
|
105
|
-
input
|
106
|
-
end
|
107
|
-
end
|
108
|
-
|
109
|
-
class Compute < Solanum::Source
|
110
|
-
def collect(current_metrics)
|
111
|
-
# Compute metrics directly, but don't let the block change the existing
|
112
|
-
# metrics in-place.
|
113
|
-
@config.call(current_metrics.dup.freeze)
|
114
|
-
end
|
115
|
-
end
|
22
|
+
end
|
116
23
|
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
require 'openssl'
|
2
|
+
require 'socket'
|
3
|
+
require 'solanum/source'
|
4
|
+
require 'solanum/util'
|
5
|
+
|
6
|
+
class Solanum::Source::Certificate < Solanum::Source
|
7
|
+
attr_reader :host, :port, :ca_cert, :expiry_states
|
8
|
+
|
9
|
+
|
10
|
+
def initialize(opts)
|
11
|
+
super(opts)
|
12
|
+
@host = opts['host'] or raise "No host provided"
|
13
|
+
@port = opts['port'] || 443
|
14
|
+
@hostname = opts['hostname'] || @host
|
15
|
+
@ca_cert = opts['ca_cert']
|
16
|
+
@expiry_states = opts['expiry_states'] || {}
|
17
|
+
end
|
18
|
+
|
19
|
+
|
20
|
+
def connect
|
21
|
+
# Configure context.
|
22
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
23
|
+
#ctx.verify_hostname = true # Only in ruby 2.x?
|
24
|
+
|
25
|
+
if @ca_cert
|
26
|
+
ctx.ca_file = @ca_cert
|
27
|
+
ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
28
|
+
end
|
29
|
+
|
30
|
+
# Open socket connection.
|
31
|
+
sock = TCPSocket.new(@host, @port)
|
32
|
+
ssl = OpenSSL::SSL::SSLSocket.new(sock, ctx)
|
33
|
+
ssl.sync_close = true
|
34
|
+
ssl.hostname = @hostname
|
35
|
+
ssl.connect
|
36
|
+
|
37
|
+
yield ssl if block_given?
|
38
|
+
ensure
|
39
|
+
if ssl
|
40
|
+
ssl.close
|
41
|
+
elsif sock
|
42
|
+
sock.close
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
|
47
|
+
# Collect metric events.
|
48
|
+
def collect!
|
49
|
+
events = []
|
50
|
+
prefix = "certificate #{@hostname}"
|
51
|
+
|
52
|
+
begin
|
53
|
+
connect do |ssl|
|
54
|
+
cert = ssl.peer_cert
|
55
|
+
|
56
|
+
# Connect okay.
|
57
|
+
events << {
|
58
|
+
service: "#{prefix} connect",
|
59
|
+
metric: 1,
|
60
|
+
state: 'ok',
|
61
|
+
description: "Certificate for #{@hostname} verified successfully",
|
62
|
+
}
|
63
|
+
|
64
|
+
# Certificate expiration time.
|
65
|
+
#puts cert.inspect
|
66
|
+
expiry = cert.not_after - Time.now
|
67
|
+
expiry_days = expiry/86400
|
68
|
+
events << {
|
69
|
+
service: "#{prefix} expiry",
|
70
|
+
metric: expiry_days,
|
71
|
+
state: state_under(@expiry_states, expiry_days),
|
72
|
+
description: "Certificate for #{@hostname} expires in #{duration_str(expiry)}",
|
73
|
+
}
|
74
|
+
end
|
75
|
+
rescue => e
|
76
|
+
# Connect error.
|
77
|
+
events << {
|
78
|
+
service: "#{prefix} connect",
|
79
|
+
metric: 1,
|
80
|
+
state: 'critical',
|
81
|
+
description: "Error connecting to #{@hostname}: #{e}",
|
82
|
+
}
|
83
|
+
end
|
84
|
+
|
85
|
+
events
|
86
|
+
end
|
87
|
+
|
88
|
+
end
|
@@ -0,0 +1,119 @@
|
|
1
|
+
require 'solanum/source'
|
2
|
+
require 'solanum/util'
|
3
|
+
|
4
|
+
class Solanum::Source::Cpu < Solanum::Source
|
5
|
+
attr_reader :detailed, :per_core, :usage_states
|
6
|
+
|
7
|
+
STAT_FILE = '/proc/stat'
|
8
|
+
STATES = %w{user nice system idle iowait irqhard irqsoft}
|
9
|
+
|
10
|
+
|
11
|
+
def initialize(opts)
|
12
|
+
super(opts)
|
13
|
+
@last = nil
|
14
|
+
@detailed = opts['detailed'] || false
|
15
|
+
@per_core = opts['per_core'] || false
|
16
|
+
@usage_states = opts['usage_states'] || {}
|
17
|
+
end
|
18
|
+
|
19
|
+
|
20
|
+
# Calculate cpu utilization from the cumulative time spent in
|
21
|
+
# 'jiffies' (1/100 sec) since system boot.
|
22
|
+
def parse_info(line)
|
23
|
+
fields = line.chomp.split(' ')
|
24
|
+
name = fields.shift
|
25
|
+
data = Hash.new(0)
|
26
|
+
STATES.each_with_index do |state, i|
|
27
|
+
data[state] = fields[i].to_i
|
28
|
+
end
|
29
|
+
return name, data
|
30
|
+
end
|
31
|
+
|
32
|
+
|
33
|
+
# Collect metric events.
|
34
|
+
def collect!
|
35
|
+
# Parse lines from usage stats.
|
36
|
+
lines = File.readlines(STAT_FILE).take_while {|l| l.start_with? 'cpu' }
|
37
|
+
totals = {}
|
38
|
+
lines.each do |line|
|
39
|
+
name, data = parse_info(line)
|
40
|
+
totals[name] = data
|
41
|
+
end
|
42
|
+
|
43
|
+
# Need at least one run to start reporting accurate metrics.
|
44
|
+
unless @last
|
45
|
+
@last = totals
|
46
|
+
return []
|
47
|
+
end
|
48
|
+
|
49
|
+
# Calculate diffs from previous measurements.
|
50
|
+
diffs = {}
|
51
|
+
totals.each do |name, data|
|
52
|
+
diffs[name] = Hash.new(0)
|
53
|
+
data.each do |state, jiffies|
|
54
|
+
last = @last[name] && @last[name][state]
|
55
|
+
diffs[name][state] = jiffies - last if last
|
56
|
+
end
|
57
|
+
end
|
58
|
+
@last = totals
|
59
|
+
|
60
|
+
# Convert diffs to relative percentages.
|
61
|
+
diffs.each do |name, diff|
|
62
|
+
elapsed = diff.values.reduce(&:+)
|
63
|
+
diff.keys.each do |state|
|
64
|
+
diff[state] = diff[state].to_f/elapsed
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
# Generate metric events.
|
69
|
+
events = []
|
70
|
+
|
71
|
+
# aggregate cpu usage
|
72
|
+
usage = 1.0 - diffs['cpu']['idle']
|
73
|
+
events << {
|
74
|
+
service: 'cpu usage',
|
75
|
+
metric: usage,
|
76
|
+
state: state_over(@usage_states, usage),
|
77
|
+
}
|
78
|
+
|
79
|
+
# detailed aggregate cpu state metrics
|
80
|
+
if @detailed
|
81
|
+
diffs['cpu'].each do |state, usage|
|
82
|
+
events << {
|
83
|
+
service: 'cpu state',
|
84
|
+
metric: usage,
|
85
|
+
cpu_state: state,
|
86
|
+
}
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# even more detailed per-core usage
|
91
|
+
if @per_core
|
92
|
+
diffs.each do |name, diff|
|
93
|
+
next if name == 'cpu'
|
94
|
+
|
95
|
+
usage = 1.0 - diff['idle']
|
96
|
+
events << {
|
97
|
+
service: 'cpu core usage',
|
98
|
+
metric: usage,
|
99
|
+
state: state_over(@usage_states, usage),
|
100
|
+
cpu_core: name,
|
101
|
+
}
|
102
|
+
|
103
|
+
if @detailed
|
104
|
+
diff.each do |state, usage|
|
105
|
+
events << {
|
106
|
+
service: 'cpu core state',
|
107
|
+
metric: usage,
|
108
|
+
cpu_core: name,
|
109
|
+
cpu_state: state,
|
110
|
+
}
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
events
|
117
|
+
end
|
118
|
+
|
119
|
+
end
|
@@ -0,0 +1,118 @@
|
|
1
|
+
require 'solanum/source'
|
2
|
+
|
3
|
+
class Solanum::Source::Diskstats < Solanum::Source
|
4
|
+
attr_reader :devices
|
5
|
+
|
6
|
+
STAT_FILE = '/proc/diskstats'
|
7
|
+
|
8
|
+
FIELDS = %w{
|
9
|
+
major minor name
|
10
|
+
reads_completed reads_merged read_sectors read_time
|
11
|
+
writes_completed writes_merged write_sectors write_time
|
12
|
+
io_active io_time io_weighted_time
|
13
|
+
}
|
14
|
+
|
15
|
+
|
16
|
+
def initialize(opts)
|
17
|
+
super(opts)
|
18
|
+
@devices = opts['devices'] || []
|
19
|
+
@detailed = opts['detailed'] || false
|
20
|
+
@last = {}
|
21
|
+
end
|
22
|
+
|
23
|
+
|
24
|
+
def parse_stats(line)
|
25
|
+
columns = line.strip.split(/\s+/)
|
26
|
+
columns = columns.take(3) + columns.drop(3).map(&:to_i)
|
27
|
+
Hash[FIELDS.zip(columns)]
|
28
|
+
end
|
29
|
+
|
30
|
+
|
31
|
+
def collect!
|
32
|
+
events = []
|
33
|
+
|
34
|
+
File.readlines(STAT_FILE).each do |line|
|
35
|
+
stats = parse_stats(line)
|
36
|
+
device = stats['name']
|
37
|
+
|
38
|
+
if @devices.include?(device) || (@devices.empty? && device =~ /^sd[a-z]$/)
|
39
|
+
if @last[device]
|
40
|
+
diff = Hash.new(0)
|
41
|
+
FIELDS.drop(3).each do |field|
|
42
|
+
diff[field] = stats[field] - @last[device][field]
|
43
|
+
end
|
44
|
+
prefix = "diskstats #{device}"
|
45
|
+
|
46
|
+
# Reads
|
47
|
+
|
48
|
+
events << {
|
49
|
+
service: "#{prefix} read sectors",
|
50
|
+
metric: diff['read_sectors'],
|
51
|
+
}
|
52
|
+
|
53
|
+
events << {
|
54
|
+
service: "#{prefix} read time",
|
55
|
+
metric: diff['read_time'],
|
56
|
+
}
|
57
|
+
|
58
|
+
if @detailed
|
59
|
+
events << {
|
60
|
+
service: "#{prefix} read completed",
|
61
|
+
metric: diff['reads_completed'],
|
62
|
+
}
|
63
|
+
|
64
|
+
events << {
|
65
|
+
service: "#{prefix} read merged",
|
66
|
+
metric: diff['reads_merged'],
|
67
|
+
}
|
68
|
+
end
|
69
|
+
|
70
|
+
# Writes
|
71
|
+
|
72
|
+
events << {
|
73
|
+
service: "#{prefix} write sectors",
|
74
|
+
metric: diff['write_sectors'],
|
75
|
+
}
|
76
|
+
|
77
|
+
events << {
|
78
|
+
service: "#{prefix} write time",
|
79
|
+
metric: diff['write_time'],
|
80
|
+
}
|
81
|
+
|
82
|
+
if @detailed
|
83
|
+
events << {
|
84
|
+
service: "#{prefix} write completed",
|
85
|
+
metric: diff['writes_completed'],
|
86
|
+
}
|
87
|
+
|
88
|
+
events << {
|
89
|
+
service: "#{prefix} write merged",
|
90
|
+
metric: diff['writes_merged'],
|
91
|
+
}
|
92
|
+
end
|
93
|
+
|
94
|
+
# IO
|
95
|
+
|
96
|
+
events << {
|
97
|
+
service: "#{prefix} io active",
|
98
|
+
metric: diff['io_active'],
|
99
|
+
}
|
100
|
+
|
101
|
+
events << {
|
102
|
+
service: "#{prefix} io time",
|
103
|
+
metric: diff['io_time'],
|
104
|
+
}
|
105
|
+
|
106
|
+
events << {
|
107
|
+
service: "#{prefix} io weighted-time",
|
108
|
+
metric: diff['io_weighted_time'],
|
109
|
+
}
|
110
|
+
end
|
111
|
+
@last[device] = stats
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
events
|
116
|
+
end
|
117
|
+
|
118
|
+
end
|