require "cgi" require "erb" require "time" require "json" require "rack" # Statusz is a tool for displaying deploy-time and runtime server information. module Statusz # @private FIELD_TO_SCRAPING_PROC = { "git directory" => Proc.new { |commit| `git rev-parse --show-toplevel`.strip.rpartition("/").last }, "latest commit" => Proc.new { |commit| `git log --pretty=%H #{commit} -n 1`.strip }, "containing branches" => Proc.new do |commit| `git branch --contains #{commit}`.strip.gsub("* ", "").gsub("\n", ", ") end, "date" => Proc.new { |commit| Time.now.strftime("%Y-%m-%d %H:%M:%S %z") }, "current user on deploy host" => Proc.new { |commit| `whoami`.strip }, "git user info" => Proc.new do |commit| "#{`git config --get user.name`.strip} <#{`git config --get user.email`.strip}>" end, "all commits" => Proc.new { |commit| `git log --pretty=%H #{commit}`.strip } } # Write out a statusz file. This should be done at deployment time. # # @param [String] filename the output filename. # @param [Hash] options the options for the output. # @option options [String] :commit The git commit for which to to output deploy information (default: HEAD). # @option options [Symbol] :format The output format (one of `:html`, `:text`, or `:json`). Default: `:html # @option options [Array] :fields The fields to include in the output. Default: all fields. # @option options [Hash] :extra_fields A hash of extra key/value pairs to include in the output. def self.write_file(filename = "./statusz.html", options = {}) options[:commit] ||= "HEAD" options[:format] ||= :html raise "Bad format: #{options[:format]}" unless [:html, :text, :json].include? options[:format] options[:fields] ||= FIELD_TO_SCRAPING_PROC.keys bad_options = options[:fields] - FIELD_TO_SCRAPING_PROC.keys raise "Bad options: #{bad_options.inspect}" unless bad_options.empty? extra_fields = options[:extra_fields] || {} unless extra_fields.is_a? Hash raise "Extra fields should be a hash, but #{extra_fields.inspect} (#{extra_fields.class}) was given." end results = {} options[:fields].each do |field| results[field] = FIELD_TO_SCRAPING_PROC[field].call(options[:commit]) end extra_fields.each { |field, value| results[field.to_s] = value.to_s } File.open(filename, "w") { |file| file.puts(render(results, options[:format])) } end # @private def self.render(fields, format) case format when :text # For readability, it's nice to have the massive 'all commits' field be the last one. all_commits = fields.delete "all commits" fields_with_commits_last = fields.map { |name, value| [name, value] } fields_with_commits_last << ["all commits", all_commits] if all_commits fields_with_commits_last.map { |name, value| "#{name}:\n#{value}" }.join("\n\n") when :json fields.to_json when :html html_values = fields.reduce({}) do |h, (field, value)| if field == "all commits" h.merge(field => value.split("\n")) else h.merge(field => CGI.escapeHTML(value.to_s)) end end ERB.new(File.read(File.join(File.dirname(__FILE__), "statusz.erb"))).result(binding) end end # @private def self.load_json_info(filename) fields = {} unless filename.nil? unless File.file? filename raise "No such file: #{filename}." end begin fields = JSON.parse(File.read(filename)) rescue StandardError => error raise "Error reading json file #{filename}: #{error.message}." end unless fields.is_a? Hash raise "Error: malformed statusz json file: #{filename}." end end fields end # If you wrote out a json file at deploy time, you can use this at runtime to turn the json file into any of # statusz's supported formats (html, json, text) and add additional runtime values. # # @param [String] filename the json statusz file written at deploy time. If `filename` is `nil`, then # statusz will output an html file containing only the fields in `extra_fields`. # @param [Symbol] format then output format (one of `:html`, `:json`, `:text`). Defaults to `:html`. # @param [Hash] extra_fields the extra key/value pairs to include in the output. def self.render_from_json(filename = "./statusz.json", format = :html, extra_fields = {}) raise "Bad format: #{format}" unless [:html, :text, :json].include? format fields = load_json_info(filename) fields.merge! extra_fields render(fields, format) end # A Rack server that can serve statusz. class Server # Set up the Statusz::Server Rack app. # # @param [String] filename the json statusz file written at deploy time. If `filename` is `nil`, then # statusz will output an html file containing only the fields in `extra_fields`. # @param [Hash] extra_fields extra key/value pairs to include in the output. def initialize(filename = "./statusz.json", extra_fields = {}) @filename = filename @extra_fields = extra_fields end # The usual Rack app call method. def call(env) headers = {} path = Rack::Request.new(env).path if path =~ /\.json$/ headers["Content-Type"] = "application/json" format = :json elsif path =~ /\.txt$/ headers["Content-Type"] = "text/plain" format = :text else headers["Content-Type"] = "text/html" format = :html end begin body = Statusz.render_from_json(@filename, format, @extra_fields) rescue StandardError => error return [500, { "Content-Type" => "text/plain" }, ["Error with statusz:\n#{error.message}"]] end [200, headers, [body]] end end end