solanum 0.2.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.
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: []