|
| 1 | +#!/opt/puppetlabs/puppet/bin/ruby |
| 2 | +# frozen_string_literal: true |
| 3 | + |
| 4 | +# rubocop::disable GetText/DecorateFunctionMessage |
| 5 | + |
| 6 | +require "optparse" |
| 7 | +require "json" |
| 8 | +require "time" |
| 9 | +require "fileutils" |
| 10 | + |
| 11 | +# This script is intended to be run on a puppet infrastructure node as part of the |
| 12 | +# puppet_metrics_collector module. It will generate system statistics in the file format that |
| 13 | +# other puppet_metrics_collector stats follow. Over a time period defined as the file interval, |
| 14 | +# it will poll the system for data based on the polling interval, then calculate an average from |
| 15 | +# the polling data to put in the file as a single data point at the end of the file interval. |
| 16 | +# sar_metric.pp will setup a cron job to run this similar to how pe_metric runs tk_metrics |
| 17 | +# |
| 18 | +# Example execution |
| 19 | +# generate_system_metrics --metric_type cpu --file_interval 300 --polling_interval 1 |
| 20 | +# --metrics_dir /opt/puppetlabs/puppet-metrics-collector |
| 21 | + |
| 22 | +# General namespace for SystemMetrics module |
| 23 | +module SystemMetrics |
| 24 | + # Main class for collecting system metrics into a json file |
| 25 | + # |
| 26 | + # @author Randell Pelak |
| 27 | + # |
| 28 | + # @attr [integer] polling_interval Time in seconds between calls to poll the system for data. |
| 29 | + # @attr [integer] file_interval Time in seconds between the creation of each output file. |
| 30 | + # @attr [string] metric_type cpu|memory |
| 31 | + # @attr [string] metrics_dir The puppet_metrics_collector output directory. |
| 32 | + # @attr [boolean] verbose Verbose output |
| 33 | + # @attr [string] hostname Name of the host the metrics are from. In directory name and json file. |
| 34 | + # |
| 35 | + class GenerateSystemMetrics |
| 36 | + # |
| 37 | + # Initialize class |
| 38 | + # |
| 39 | + # @author Randell Pelak |
| 40 | + # |
| 41 | + # @param [integer] polling_interval Time in seconds between calls to poll the system for data. |
| 42 | + # @param [integer] file_interval Time in seconds between the creation of each output file. |
| 43 | + # @param [string] metric_type cpu|memory |
| 44 | + # @param [string] metrics_dir The puppet_metrics_collector output directory. |
| 45 | + # @param [boolean] verbose Verbose output |
| 46 | + # |
| 47 | + # @return [void] |
| 48 | + def initialize(polling_interval, file_interval, metric_type, metrics_dir, verbose = false) |
| 49 | + @polling_interval = polling_interval |
| 50 | + @file_interval = file_interval |
| 51 | + @metric_type = metric_type |
| 52 | + @metrics_dir = metrics_dir |
| 53 | + @verbose = verbose |
| 54 | + |
| 55 | + @hostname = %x[hostname].strip |
| 56 | + puts "Hostname is: #{@hostname}" if @verbose |
| 57 | + FileUtils.mkdir_p(@metrics_dir) unless File.directory?(@metrics_dir) |
| 58 | + end |
| 59 | + |
| 60 | + # Run sar to collect the raw system data |
| 61 | + # |
| 62 | + # @author Randell Pelak |
| 63 | + # |
| 64 | + # @return [string] raw output from sar |
| 65 | + def run_sar |
| 66 | + times_to_poll = (@file_interval / @polling_interval).round |
| 67 | + # sar inputs are polling interval and how many times to poll |
| 68 | + comm_flags = " -r" if @metric_type =~ /memory/ |
| 69 | + comm = "sar #{comm_flags} #{@polling_interval} #{times_to_poll}" |
| 70 | + puts "sar command is: #{comm}" if @verbose |
| 71 | + %x[#{comm}] |
| 72 | + end |
| 73 | + |
| 74 | + # Parse the sar output and extract the metrics data |
| 75 | + # |
| 76 | + # @author Randell Pelak |
| 77 | + # |
| 78 | + # @param [array] sar_output |
| 79 | + # |
| 80 | + # @raise [RuntimeError] if sar_output doesn't parse correctly |
| 81 | + # |
| 82 | + # @return [hash] The metrics data |
| 83 | + def parse_sar_output(sar_output) |
| 84 | + sar_output_arr = sar_output.split(/\n+|\r+/).reject(&:empty?).map { |line| line.split } |
| 85 | + |
| 86 | + if ( @metric_type == "memory") |
| 87 | + unique_header_str = "%memused" |
| 88 | + else |
| 89 | + unique_header_str = "%user" |
| 90 | + end |
| 91 | + headers_line = sar_output_arr.find { |e| e.include? unique_header_str } |
| 92 | + |
| 93 | + sar_error_missing_headers = "sar output invalid or missing headers." + |
| 94 | + "failed to find line with #{unique_header_str}." |
| 95 | + "\nFull output:\n#{sar_output}" |
| 96 | + raise(sar_error_missing_headers) if headers_line.nil? |
| 97 | + |
| 98 | + averages_line = sar_output_arr.find { |e| e.include? "Average:" } |
| 99 | + sar_error_missing_averages = "sar output missing \"Average:\"\nFull output:\n#{sar_output}" |
| 100 | + raise(sar_error_missing_averages) if averages_line.nil? |
| 101 | + |
| 102 | + Hash[headers_line.reverse.zip(averages_line.reverse).reverse] |
| 103 | + |
| 104 | + puts "sar headers and averages:\n#{headers_line.join(",")}\n#{averages_line.join(",")}" if @verbose |
| 105 | + |
| 106 | + #example of array data |
| 107 | + # 04:59:13,PM,CPU,%user,%nice,%system,%iowait,%steal,%idle |
| 108 | + # Average:,all,0.58,0.00,0.08,0.00,0.00,99.33 |
| 109 | + #combine the arrays into a hash starting from the deal with the unmatched columns in the front |
| 110 | + data_hash = Hash[headers_line.reverse.zip(averages_line.reverse).reverse] |
| 111 | + # remove anything that doesn't have a number for an average like "Average:" or "all" |
| 112 | + data_hash.select{ |k,v| v =~ /\A[-+]?[0-9]*\.?[0-9]+\Z/ } |
| 113 | + end |
| 114 | + |
| 115 | + # Convert the inputted sar output into json and raise errors if the sar output is invalid |
| 116 | + # |
| 117 | + # @author Randell Pelak |
| 118 | + # |
| 119 | + # @param [string] sar_output |
| 120 | + # @param [obj] time_stamp_obj Time object to use for generating the filename |
| 121 | + # |
| 122 | + # @raise [RuntimeError] if sar_output doesn't parse correctly |
| 123 | + # |
| 124 | + # @return [string] json of sar output |
| 125 | + def convert_sar_output_to_json(sar_output, time_stamp_obj) |
| 126 | + hostkey = @hostname.gsub('.', '-') |
| 127 | + dataset = {'time_stamp_obj' => time_stamp_obj.utc.iso8601, 'servers' => {}} |
| 128 | + metrics_data = parse_sar_output(sar_output) |
| 129 | + dataset['servers'][hostkey] = {@metric_type => metrics_data} |
| 130 | + json_dataset = JSON.pretty_generate(dataset) |
| 131 | + return json_dataset |
| 132 | + end |
| 133 | + |
| 134 | + # Create the file and put the json data in it |
| 135 | + # |
| 136 | + # @author Randell Pelak |
| 137 | + # |
| 138 | + # @param [string] json_dataset data in json format to put in file |
| 139 | + # @param [obj] time_stamp_obj Time object to use for generating the filename |
| 140 | + # |
| 141 | + # @return [void] |
| 142 | + def create_file(json_dataset, time_stamp_obj) |
| 143 | + filename = time_stamp_obj.utc.strftime('%Y%m%dT%H%M%SZ') + '.json' |
| 144 | + dirname = "#{@metrics_dir}/system_#{@metric_type}/#{@hostname}" |
| 145 | + file_path = "#{dirname}/#{filename}" |
| 146 | + FileUtils.mkdir_p(dirname) unless File.directory?(dirname) |
| 147 | + puts "Creating json file: #{file_path}" if @verbose |
| 148 | + File.write(file_path, json_dataset) |
| 149 | + end |
| 150 | + |
| 151 | + # Get the data and create the file |
| 152 | + # |
| 153 | + # @author Randell Pelak |
| 154 | + # |
| 155 | + # @return [void] |
| 156 | + def generate_system_metrics |
| 157 | + sar_output = run_sar |
| 158 | + # time stamp generated after sar run to be consistent with |
| 159 | + # pe metrics from puppet metrics collector |
| 160 | + time_stamp_obj = Time.now |
| 161 | + json_data = convert_sar_output_to_json(sar_output, time_stamp_obj) |
| 162 | + create_file(json_data, time_stamp_obj) |
| 163 | + end |
| 164 | + end |
| 165 | +end |
| 166 | + |
| 167 | +if $PROGRAM_NAME == __FILE__ |
| 168 | + |
| 169 | + VALID_METRIC_TYPES = %w[cpu memory] |
| 170 | + FILE_INTERVAL_DEFAULT = 60 * 5 |
| 171 | + POLLING_INTERVAL_DEFAULT = 1 |
| 172 | + METRIC_TYPE_DEFAULT = "cpu" |
| 173 | + METRICS_DIR_DEFAULT = "/opt/puppetlabs/puppet-metrics-collector" |
| 174 | + |
| 175 | + DESCRIPTION = <<-DESCRIPTION |
| 176 | + This script is intended to be run on a puppet infrastructure node as part of the |
| 177 | + puppet_metrics_collector module. It will generate system statistics in the file format that |
| 178 | + other puppet_metrics_collector stats follow. It will poll the system for data at a given |
| 179 | + interval, and then output the average to a file once per given file interval. |
| 180 | + DESCRIPTION |
| 181 | + |
| 182 | + DEFAULTS = <<-DEFAULTS |
| 183 | + The following default values are used if the options are not specified: |
| 184 | + * polling_interval (-p, --polling_interval): #{POLLING_INTERVAL_DEFAULT} |
| 185 | + * file_interval (-f, --file_interval): #{FILE_INTERVAL_DEFAULT} |
| 186 | + * metric_type (-t, --metric_type): #{METRIC_TYPE_DEFAULT} |
| 187 | + * metrics_dir (-m, --metrics_dir): #{METRICS_DIR_DEFAULT} |
| 188 | + * verbose (-v, --verbose): False |
| 189 | + DEFAULTS |
| 190 | + |
| 191 | + options = {} |
| 192 | + |
| 193 | + OptionParser.new do |opts| |
| 194 | + opts.banner = "Usage: generate_system_stats.rb [options]" |
| 195 | + |
| 196 | + opts.on("-h", "--help", "Display the help text") do |
| 197 | + puts DESCRIPTION |
| 198 | + puts opts |
| 199 | + puts DEFAULTS |
| 200 | + exit |
| 201 | + end |
| 202 | + |
| 203 | + opts.on("-p", "--polling_interval seconds", Integer, |
| 204 | + "Time in seconds between calls to poll the system for data.") do |interval| |
| 205 | + options[:polling_interval] = interval |
| 206 | + end |
| 207 | + opts.on("-f", "--file_interval seconds", Integer, |
| 208 | + "Time in seconds between the creation of each output file.") do |interval| |
| 209 | + options[:file_interval] = interval |
| 210 | + end |
| 211 | + opts.on("-t", "--metric_type type", String, |
| 212 | + "One of: #{VALID_METRIC_TYPES.join(', ')}") do |type| |
| 213 | + options[:metric_type] = type.downcase |
| 214 | + end |
| 215 | + opts.on("-m", "--metrics_dir dir_path", String, |
| 216 | + "The puppet_metrics_collector output directory") do |metrics_dir| |
| 217 | + options[:metrics_dir] = metrics_dir |
| 218 | + end |
| 219 | + opts.on("-v", "--verbose", String, "Enable Verbose output") { options[:verbose] = true } |
| 220 | + end.parse! |
| 221 | + |
| 222 | + if options[:metric_type] |
| 223 | + unless VALID_METRIC_TYPES.include?(options[:metric_type]) |
| 224 | + options_error = "Invalid metric type #{options[:metric_type]}." + |
| 225 | + " Must be one of: #{VALID_METRIC_TYPES.join(', ')}." |
| 226 | + raise options_error |
| 227 | + end |
| 228 | + end |
| 229 | + |
| 230 | + polling_interval = options[:polling_interval] || POLLING_INTERVAL_DEFAULT |
| 231 | + file_interval = options[:file_interval] || FILE_INTERVAL_DEFAULT |
| 232 | + metric_type = options[:metric_type] || METRIC_TYPE_DEFAULT |
| 233 | + metrics_dir = options[:metrics_dir] || METRICS_DIR_DEFAULT |
| 234 | + verbose = options[:verbose] || false |
| 235 | + |
| 236 | + if options[:polling_interval] || options[:file_interval] |
| 237 | + options_error = "Polling interval must be less than file interval" |
| 238 | + raise options_error unless polling_interval < file_interval |
| 239 | + end |
| 240 | + |
| 241 | + if verbose |
| 242 | + OPTION_SETTINGS = <<-SETTINGS |
| 243 | + The following are the resulting options settings: |
| 244 | + * polling_interval: #{polling_interval} |
| 245 | + * file_interval: #{file_interval} |
| 246 | + * metric_type: #{metric_type} |
| 247 | + * metrics_dir: #{metrics_dir} |
| 248 | + * verbose: #{verbose} |
| 249 | + SETTINGS |
| 250 | + puts OPTION_SETTINGS |
| 251 | + end |
| 252 | + |
| 253 | + obj = SystemMetrics::GenerateSystemMetrics.new(polling_interval, file_interval, metric_type, |
| 254 | + metrics_dir, verbose) |
| 255 | + obj.generate_system_metrics |
| 256 | +end |
0 commit comments