solanum 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: e9ebaaa5f2e140d9a0b2d821bc685a363f103ea1
4
+ data.tar.gz: 4fc6fbb8e60b919d8a9d1d8c8d36d557a80269ca
5
+ SHA512:
6
+ metadata.gz: 30e99d6aa3fba2cf78f4ba69bde44e799212ca457a7de325ee40585e111c237188b3b1d0770c642a86641da5c253eab09a64310ae4a39fc2f7be6cee19a7d4f6
7
+ data.tar.gz: 254e37a291a4e3925e3a0e38cb7fde12f4a177508ba0354ed6ba035d2844fd086411ed040eedb63d421f51f2841f4d9277d9b8b32a26f232497f547a2a7e0160
data/README.md ADDED
@@ -0,0 +1,53 @@
1
+ Solanum
2
+ =======
3
+
4
+ This gem provides a domain-specific language (DSL) for collecting metrics
5
+ data in Ruby. The `solanum` script takes a number of monitoring configuration
6
+ scripts as arguments and periodically collects the metrics defined. The results
7
+ can be printed to the console or sent to a [Riemann](http://riemann.io/) server.
8
+ This requires the `riemann-client` gem to work.
9
+
10
+ ## Structure
11
+
12
+ Solanum scripts define _sources_, which provide some string input when they are
13
+ read. This input is processed by a set of _matchers_ for each source, which can
14
+ generate named measurements from that data. A simple example would be a file
15
+ source, which is read and matched line-by-line against a set of regular
16
+ expressions.
17
+
18
+ The emitted measurements can undergo a bit more processing before being
19
+ reported. For example, some metrics are monotonically-increasing counters, and
20
+ what we actually want is the _difference_ between each reading. For others, we
21
+ may want to apply threshold-based states to the events. These are set by
22
+ _service prototypes_, which are also defined in the scripts.
23
+
24
+ ## Examples
25
+
26
+ Here's an example of reading some information about the current system memory:
27
+
28
+ ```ruby
29
+ # Read memory usage.
30
+ read "/proc/meminfo" do
31
+ match /^MemTotal:\s+(\d+) kB$/, cast: :to_i, scale: 1024, record: 'memory total bytes'
32
+ match /^MemFree:\s+(\d+) kB$/, cast: :to_i, scale: 1024, record: 'memory free bytes'
33
+ end
34
+
35
+ # Calculate percentages from total space.
36
+ compute do |metrics|
37
+ total = metrics['memory total bytes']
38
+ free = metrics['memory free bytes']
39
+ if total && free
40
+ metrics['memory free pct'] = free.to_f/total
41
+ end
42
+ end
43
+
44
+ # Define a service prototype with a threshold-based state.
45
+ service 'memory free pct', state: thresholds(0.00, :critical, 0.10, :warning, 0.25, :ok)
46
+ ```
47
+
48
+ See the files in the `examples` directory for more monitor configuration samples.
49
+
50
+ ## License
51
+
52
+ This is free and unencumbered software released into the public domain.
53
+ See the UNLICENSE file for more information.
data/bin/solanum ADDED
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $: << File.expand_path("../../lib", __FILE__)
4
+
5
+ require 'optparse'
6
+ require 'solanum'
7
+
8
+ $options = {
9
+ riemann_host: nil,
10
+ riemann_port: 5555,
11
+ interval: 5,
12
+ verbose: false,
13
+ }
14
+
15
+ $defaults = {
16
+ host: %x{hostname}.chomp,
17
+ tags: [],
18
+ ttl: 10,
19
+ }
20
+
21
+ def fail(msg, code=1)
22
+ STDERR.puts(msg)
23
+ exit code
24
+ end
25
+
26
+ def log(msg)
27
+ puts "%s %s" % [Time.now.strftime("%H:%M:%S"), msg] if $options[:verbose]
28
+ end
29
+
30
+ # Parse command-line options.
31
+ options = OptionParser.new do |opts|
32
+ opts.banner = "Usage: #{File.basename($0)} [options] <monitor config> [monitor config] ..."
33
+ opts.separator ""
34
+ opts.separator "Event Attributes:"
35
+ opts.on( '--host HOST', "Event hostname (default: #{$defaults[:host]})") {|v| $defaults[:host] = v }
36
+ opts.on('-a', '--attribute KEY=VAL', "Attribute to add to the event (may be given multiple times)") {|attr| k,v = attr.split(/=/); if k and v then $defaults[k.intern] = v end }
37
+ opts.on('-t', '--tag TAG', "Tag to add to events (may be given multiple times)") {|v| $defaults[:tags] << v }
38
+ opts.on( '--ttl SECONDS', "Default TTL for events (default: #{$options[:ttl]})") {|v| $defaults[:ttl] = v.to_i }
39
+ opts.separator ""
40
+ opts.separator "General Options:"
41
+ opts.on( '--riemann-host HOST', "Riemann host to report events to") {|v| $options[:riemann_host] = v }
42
+ opts.on( '--riemann-port PORT', "Riemann port (default: #{$options[:riemann_port]})") {|v| $options[:riemann_port] = v.to_i }
43
+ opts.on('-i', '--interval SECONDS', "Seconds between updates (default: #{$options[:interval]})") {|v| $options[:interval] = v.to_i }
44
+ opts.on('-v', '--verbose', "Print additional information to stdout") { $options[:verbose] = true }
45
+ opts.on('-h', '--help', "Displays usage information") { print opts; exit }
46
+ end
47
+ options.parse!
48
+
49
+ # Check usage.
50
+ fail options if ARGV.empty?
51
+
52
+
53
+
54
+ ##### MONITORING CONFIGS #####
55
+
56
+ $solanum = Solanum.new(ARGV)
57
+ fail "No sources loaded!" if $solanum.sources.empty?
58
+
59
+ if $options[:riemann_host]
60
+ begin
61
+ require 'riemann/client'
62
+ rescue LoadError
63
+ fail "ERROR: could not load Riemann client library! `gem install riemann-client` to enable reporting"
64
+ end
65
+
66
+ $riemann = Riemann::Client.new(host: $options[:riemann_host], port: $options[:riemann_port])
67
+ end
68
+
69
+
70
+
71
+ ##### REPORT LOOP #####
72
+
73
+ trap "SIGINT" do
74
+ exit
75
+ end
76
+
77
+ loop do
78
+ $solanum.collect!
79
+ events = $solanum.build_events($defaults)
80
+
81
+ events.each do |event|
82
+ if $options[:verbose] || $riemann.nil?
83
+ puts "%-40s %5s (%s) %s" % [
84
+ event[:service], event[:metric],
85
+ event[:state].nil? ? "--" : event[:state],
86
+ event.inspect
87
+ ]
88
+ end
89
+
90
+ $riemann << event if $riemann
91
+ end
92
+
93
+ sleep $options[:interval]
94
+ end
@@ -0,0 +1,97 @@
1
+ require 'solanum/source'
2
+
3
+ class Solanum::Config
4
+ attr_reader :sources, :services
5
+
6
+ def initialize(path)
7
+ @sources = []
8
+ @services = []
9
+
10
+ instance_eval ::File.readlines(path).join, path, 1
11
+
12
+ raise "No sources loaded from monitor script: #{path}" if @sources.empty?
13
+ end
14
+
15
+
16
+ private
17
+
18
+ # Registers a new source object. If a block is given, it is used to configure
19
+ # the source with instance_exec.
20
+ def register_source(source, config=nil)
21
+ source.instance_exec &config if config
22
+ @sources << source
23
+ source
24
+ end
25
+
26
+
27
+ # Registers a source which runs a command and matches against output lines.
28
+ def run(command, &config)
29
+ register_source Solanum::Source::Command.new(command), config
30
+ end
31
+
32
+
33
+ # Registers a source which matches against the lines in a file.
34
+ def read(path, &config)
35
+ register_source Solanum::Source::File.new(path), config
36
+ end
37
+
38
+
39
+ # Registers a source which computes metrics directly.
40
+ def compute(&block)
41
+ register_source Solanum::Source::Compute.new(block)
42
+ end
43
+
44
+
45
+ # Registers a pair of [matcher, prototype] where matcher is generally a string
46
+ # or regex to match a service name, and prototype is a map of :ttl, :state,
47
+ # :tags, etc.
48
+ def service(service, prototype={})
49
+ @services << [service, prototype]
50
+ end
51
+
52
+
53
+ ##### HELPER METHODS #####
54
+
55
+ # Creates a state function based on thresholds. If the first argument is a
56
+ # symbol, it is taken as the default service state. Otherwise, arguments should
57
+ # be alternating numeric thresholds and state values to assign if the metric
58
+ # value exceeds the threshold.
59
+ #
60
+ # For example, for an 'availability' metric you often want to warn on low
61
+ # values. To assign a 'critical' state to values between 0% and 10%,
62
+ # 'warning' between 10% and 25%, and 'ok' above, use the following:
63
+ #
64
+ # thresholds(0.00, :critical, 0.10, :warning, 0.25, :ok)
65
+ #
66
+ # For 'usage' metrics it's the inverse, giving low values ok states and
67
+ # warning about high values:
68
+ #
69
+ # thresholds(:ok, 55, :warning, 65, :critical)
70
+ #
71
+ def thresholds(*args)
72
+ default_state = nil
73
+ default_state = args.shift unless args.first.kind_of? Numeric
74
+
75
+ # Check arguments.
76
+ raise "Thresholds must be paired with state values" unless args.count.even?
77
+ args.each_slice(2) do |threshold|
78
+ limit, state = *threshold
79
+ raise "Limits must be numeric: #{limit}" unless limit.kind_of? Numeric
80
+ raise "State values must be strings or symbols: #{state}" unless state.instance_of?(String) || state.instance_of?(Symbol)
81
+ end
82
+
83
+ # State block.
84
+ lambda do |v|
85
+ state = default_state
86
+ args.each_slice(2) do |threshold|
87
+ if threshold[0] < v
88
+ state = threshold[1]
89
+ else
90
+ break
91
+ end
92
+ end
93
+ state
94
+ end
95
+ end
96
+
97
+ end
@@ -0,0 +1,70 @@
1
+ require 'json'
2
+
3
+ # A matcher takes in an input string and returns a hash of measurement names to
4
+ # numeric values.
5
+ #
6
+ # Author:: Greg Look
7
+ class Solanum::Matcher
8
+ attr_reader :fn
9
+
10
+ # Creates a new Matcher which will run the given function on input.
11
+ def initialize(fn)
12
+ raise "function must be provided" if fn.nil?
13
+ @fn = fn
14
+ end
15
+
16
+ # Attempts to match the given input, returning a hash of metrics.
17
+ def call(input)
18
+ {}
19
+ end
20
+
21
+
22
+ ### MATCHER TYPES ###
23
+
24
+ public
25
+
26
+ # LinePattern matchers define a regular expression which is tested against
27
+ # each line of input. The given function is called for **each** matched line,
28
+ # and the resulting measurements are merged together.
29
+ class LinePattern < Solanum::Matcher
30
+ def initialize(fn, pattern)
31
+ super fn
32
+ raise "pattern must be provided" if pattern.nil?
33
+ @pattern = pattern
34
+ end
35
+
36
+ def call(input)
37
+ raise "No input provided!" if input.nil?
38
+ lines = input.split("\n")
39
+ metrics = {}
40
+
41
+ lines.each do |line|
42
+ begin
43
+ if @pattern === line
44
+ measurements = @fn.call($~)
45
+ metrics.merge!(measurements) if measurements
46
+ end
47
+ rescue => e
48
+ STDERR.puts("Error calculating metrics from line match: #{e.inspect}")
49
+ end
50
+ end
51
+
52
+ metrics
53
+ end
54
+ end
55
+
56
+
57
+ # JsonReader matches a JSON-formatted input string and passes the parsed
58
+ # object to the block body.
59
+ class JSONReader < Solanum::Matcher
60
+ def call(input)
61
+ begin
62
+ json = JSON.parse(input)
63
+ @fn.call(json)
64
+ rescue => e
65
+ STDERR.puts("Error matching JSON input: #{e.inspect}")
66
+ {}
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,116 @@
1
+ require 'solanum/matcher'
2
+
3
+ # This class represents a source of data, whether read from command output,
4
+ # a file on the system, or just calculated from other values. Each source
5
+ # may have multiple matchers, which will be run against the input data to
6
+ # produce metrics. Each matcher sees the _whole_ input.
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
33
+ end
34
+
35
+ private
36
+
37
+ # Collect input data from the given source.
38
+ def read_input!
39
+ nil
40
+ end
41
+
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
+
52
+ if options[:record]
53
+ block = lambda do |matches|
54
+ value = matches[1]
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)
67
+ end
68
+
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
116
+ end
data/lib/solanum.rb ADDED
@@ -0,0 +1,81 @@
1
+ # Class which wraps up an active Solanum monitoring system into an object.
2
+ #
3
+ # Author:: Greg Look
4
+ class Solanum
5
+ attr_reader :sources, :services, :metrics
6
+
7
+ require 'solanum/config'
8
+ require 'solanum/source'
9
+
10
+
11
+ # Loads the given monitoring scripts and initializes the sources and service
12
+ # definitions.
13
+ def initialize(scripts)
14
+ @sources = []
15
+ @services = []
16
+ @metrics = {}
17
+
18
+ scripts.each do |path|
19
+ begin
20
+ config = Solanum::Config.new(path)
21
+ @sources.concat(config.sources)
22
+ @services.concat(config.services)
23
+ rescue => e
24
+ STDERR.puts "Error loading monitor script #{path}: #{e}"
25
+ end
26
+ end
27
+
28
+ @sources.freeze
29
+ @services.freeze
30
+ end
31
+
32
+
33
+ # Collects metrics from the given sources, in order. Updates the internal
34
+ # merged map of metric data.
35
+ def collect!
36
+ @old_metrics = @metrics
37
+ @metrics = @sources.reduce({}) do |metrics, source|
38
+ begin
39
+ new_metrics = source.collect(metrics) || {}
40
+ metrics.merge(new_metrics)
41
+ rescue => e
42
+ STDERR.puts "Error collecting metrics from #{source}: #{e}"
43
+ metrics
44
+ end
45
+ end
46
+ end
47
+
48
+
49
+ # Builds full events from a set of service prototypes, old metrics, and new
50
+ # metrics.
51
+ def build_events(defaults={})
52
+ @metrics.keys.sort.map do |service|
53
+ value = @metrics[service]
54
+ prototype = @services.select{|m| m[0] === service }.map{|m| m[1] }.reduce({}, &:merge)
55
+
56
+ state = prototype[:state] ? prototype[:state].call(value) : :ok
57
+ tags = ((prototype[:tags] || []) + (defaults[:tags] || [])).uniq
58
+ ttl = prototype[:ttl] || defaults[:ttl]
59
+
60
+ if prototype[:diff]
61
+ last = @old_metrics[service]
62
+ if last && last <= value
63
+ value = value - last
64
+ else
65
+ value = nil
66
+ end
67
+ end
68
+
69
+ if value
70
+ defaults.merge({
71
+ service: service,
72
+ metric: value,
73
+ state: state.to_s,
74
+ tags: tags,
75
+ ttl: ttl
76
+ })
77
+ end
78
+ end.compact
79
+ end
80
+
81
+ end
metadata ADDED
@@ -0,0 +1,64 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: solanum
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Greg Look
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-07-28 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: riemann-client
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - '>='
18
+ - !ruby/object:Gem::Version
19
+ version: 0.2.2
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - '>='
25
+ - !ruby/object:Gem::Version
26
+ version: 0.2.2
27
+ description:
28
+ email: greg@greg-look.net
29
+ executables:
30
+ - solanum
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - lib/solanum/config.rb
35
+ - lib/solanum/matcher.rb
36
+ - lib/solanum/source.rb
37
+ - lib/solanum.rb
38
+ - bin/solanum
39
+ - README.md
40
+ homepage: https://github.com/greglook/solanum
41
+ licenses:
42
+ - Public Domain
43
+ metadata: {}
44
+ post_install_message:
45
+ rdoc_options: []
46
+ require_paths:
47
+ - lib
48
+ required_ruby_version: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - '>='
51
+ - !ruby/object:Gem::Version
52
+ version: 1.9.1
53
+ required_rubygems_version: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - '>='
56
+ - !ruby/object:Gem::Version
57
+ version: '0'
58
+ requirements: []
59
+ rubyforge_project:
60
+ rubygems_version: 2.0.14
61
+ signing_key:
62
+ specification_version: 4
63
+ summary: DSL for custom monitoring configuration
64
+ test_files: []