class GrafanaRb::Cli

Constants

DEFAULT_GRAFANA_PASSWORD
DEFAULT_GRAFANA_USER
MARKER
REQUIRE_DEFAULT_TEMPLATE
TAG

Public Class Methods

new(argv) click to toggle source
# File lib/grafana-rb/cli.rb, line 9
def initialize(argv)
  @argv = argv
  @workdir = Dir.pwd
  @panel_id = 0
end

Public Instance Methods

run() click to toggle source
# File lib/grafana-rb/cli.rb, line 15
def run
  if File.directory?(@argv[0].to_s)
    @workdir = @argv.shift
  end

  if @argv[0] == "--vault" && File.exists?(@argv[1])
    @argv.shift
    @vault_file = @argv.shift
  elsif File.exists?(File.join(Dir.pwd, "vault.key"))
    @vault_file = File.join(Dir.pwd, "vault.key")
  else
    @vault_file = nil
  end

  if %w[apply a q].index(@argv[0])
    apply

  elsif %w[encrypt en e].index(@argv[0])
    puts encrypt(@argv[1])

  elsif %w[decrypt de d].index(@argv[0])
    puts decrypt(@argv[1])

  else
    usage
  end
end

Private Instance Methods

apply() click to toggle source
# File lib/grafana-rb/cli.rb, line 404
def apply
  check_config_existance
  die("missed grafana_url variable") unless config["grafana_url"]

  tool_version = config["tool_version"] || GrafanaRb::VERSION
  if compare_versions(tool_version, GrafanaRb::VERSION) > 0
    puts "FATAL: need at least '#{tool_version}' tool version but current version is '#{GrafanaRb::VERSION}'"
    exit -1
  end

  create_or_update_datasources
  create_or_update_slack_notifications
  dashboards.each do |dashboard|
    create_or_update_dashboard(deep_clone(dashboard))
  end
  remove_obsole_dashboards
  puts "done."
end
bin_to_hex(s) click to toggle source
# File lib/grafana-rb/cli.rb, line 50
def bin_to_hex(s)
  s.unpack('H*').first
end
check_config_existance() click to toggle source
# File lib/grafana-rb/cli.rb, line 122
def check_config_existance
  unless File.exists?(config_file)
    puts "FATAL: missed #{config_file} in working directory"
    puts "#{config_file} example:"
    puts ""
    puts "---"
    puts "tool_version: #{GrafanaRb::VERSION}"
    puts "grafana_url: http://monitoring.your-domain.com:3000     # required"
    puts "grafana_user: user                  # 'admin' if missed"
    puts "grafana_password: secret            # 'admin' if missed"
    puts "require: [main.yml, dash*.yml]      # optional, [*.yml] by default"
    puts "datasources: ...                    # datasources"
    puts "notifications: ...                  # notifications"
    puts ""
    die
  end
end
compare_versions(a, b) click to toggle source
# File lib/grafana-rb/cli.rb, line 423
def compare_versions(a, b)
  parse = proc { |v| v.split(".", 3).map(&:to_i) + [v.index("-dev") ? 1 : 0] }
  parse.call(a) <=> parse.call(b)
end
config() click to toggle source
# File lib/grafana-rb/cli.rb, line 155
def config
  @config ||= read_yaml(config_file) || {}
end
config_file() click to toggle source
# File lib/grafana-rb/cli.rb, line 78
def config_file 
  @config_file ||= File.join(@workdir, "grafana.yml")
end
create_or_update_dashboard(config) click to toggle source
# File lib/grafana-rb/cli.rb, line 353
def create_or_update_dashboard(config)
  editable = config.delete("editable") { true }
  duration = config.delete("duration") { "30m" }
  refresh = config.delete("refresh") { "10s" }
  datasource = config.delete("datasource") { "unknown" }
  notification = config.delete("notification") { nil }
  name = config.delete("name")
  puts "create/update dashboard '#{name}'"

  unless @datasource_ids.key?(datasource)
    puts "FATAL: unknown datasource for #{name} dashboard"
    exit
  end

  rows = config.map { |name, group| 
    group = {"panels" => group} if group.is_a?(Array)
    {
      title: group["title"] || name,
      showTitle: group.delete("showTitle") { true },
      height: group["height"] || 200,
      panels: group["panels"].map { |panel| gen_panel((@panel_id += 1), @datasource_ids[datasource], @notif_ids[notification], panel) }
    }
  }

  hr = request(:post, "/api/dashboards/db", {
    dashboard: {
      title: name,
      tags: [TAG],
      editable: editable,
      time: {from: "now-#{duration}", to: "now"},
      refresh: refresh,
      rows: rows
    },
    overwrite: true
  })

  die(hr.inspect) unless hr["slug"]
end
create_or_update_datasources() click to toggle source
# File lib/grafana-rb/cli.rb, line 181
def create_or_update_datasources
  @datasource_ids = {}
  (config["datasources"] || []).each do |payload|
    if @datasource_ids[payload["name"]] = request(:get, "/api/datasources/id/#{payload["name"]}")["id"]
      puts "update datasource: #{payload["name"]}"
      request(:put, "/api/datasources/#{@datasource_ids[payload["name"]]}", payload)
    else
      puts "create datasource: #{payload["name"]}"
      @datasource_ids[payload["name"]] = request(:post, "/api/datasources", payload)["id"]
    end
  end
end
create_or_update_slack_notifications() click to toggle source
# File lib/grafana-rb/cli.rb, line 194
def create_or_update_slack_notifications
  @notif_ids = {}
  (config["notifications"] || []).each do |payload|
    notif = request(:get, "/api/alert-notifications").find{ |s| s["name"] == payload["name"] }
    if notif
      method = :put
      url = "/api/alert-notifications/#{notif["id"]}"
      opts = {id: notif["id"]}
    else
      method = :post
      url = "/api/alert-notifications"
      opts = {}
    end
    puts "create/update slack notifications: #{payload["name"]}"
    @notif_ids[payload["name"]] = request(method, url, opts.merge(payload))["id"]
  end
end
dashboards() click to toggle source
# File lib/grafana-rb/cli.rb, line 173
def dashboards
  @dashboards ||= require_files.map { |path| 
    yaml = read_yaml(path)
    yaml["name"] ||= File.basename(path, File.extname(path))
    yaml
  }
end
decrypt(string) click to toggle source
# File lib/grafana-rb/cli.rb, line 68
def decrypt(string)
  die "missed vault file" unless @vault_file
  die "string not encrypted" unless string.index(MARKER) == 0
  cipher = OpenSSL::Cipher::AES256.new :CBC
  cipher.decrypt
  cipher.iv = hex_to_bin(string.sub(MARKER, "").split(":")[1])
  cipher.key = Digest::SHA256.digest(File.read(@vault_file).strip)
  cipher.update(hex_to_bin(string.sub(MARKER, "").split(":")[0])) + cipher.final
end
deep_clone(hash) click to toggle source
# File lib/grafana-rb/cli.rb, line 400
def deep_clone(hash)
  JSON.load(JSON.dump(hash))
end
die(msg = nil) click to toggle source
# File lib/grafana-rb/cli.rb, line 113
def die(msg = nil)
  if msg
    puts "FATAL: #{msg}"
  else
    puts "exit with status code -1"
  end
  exit -1
end
encrypt(string) click to toggle source
# File lib/grafana-rb/cli.rb, line 58
def encrypt(string)
  die "missed vault file" unless @vault_file
  die "string encrypted yet" if string.index(MARKER) == 0
  cipher = OpenSSL::Cipher::AES256.new :CBC
  cipher.encrypt
  iv = cipher.random_iv
  cipher.key = Digest::SHA256.digest(File.read(@vault_file).strip)
  MARKER + bin_to_hex(cipher.update(string) + cipher.final) + ":" + bin_to_hex(iv)
end
gen_panel(id, datasource_id, notif_id, desc) click to toggle source
# File lib/grafana-rb/cli.rb, line 241
def gen_panel(id, datasource_id, notif_id, desc)
  if desc["type"] == "text"
    {
      content: desc["content"].to_s,
      id: id,
      mode: "markdown",
      span: desc["span"] || 2,
      title: desc["title"].to_s,
      type: "text"
    }

  elsif desc["type"] == "memory"
    title = desc.delete("title")
    gen_panel_custom(id, datasource_id, desc.merge(JSON.load(JSON.dump({
      title: desc["target"] + " - mem",
      stack: true,
      fill: 8,
      format: "decbytes",
      exprs: [
        {expr: 'node_memory_MemTotal{instance="$target"} - node_memory_Cached{instance="$target"} - node_memory_Buffers{instance="$target"} - node_memory_MemFree{instance="$target"}', title: "Used", color: "#890F02"},
        {expr: 'node_memory_Cached{instance="$target"}', title: "Cached", color: "#0A437C"},
        {expr: 'node_memory_Buffers{instance="$target"}', title: "Buffers", color: "#1F78C1"},
        {expr: 'node_memory_MemFree{instance="$target"}', title: "Free", color: "#BADFF4"}
      ]
    }))))

  elsif desc["type"] == "disk"
    raise "missed 'disk' field for disk panel" unless desc["disk"]
    gen_panel_custom(id, datasource_id, desc.merge(JSON.load(JSON.dump({
      title: desc["target"] + " - #{desc["disk"]}",
      fill: 0,
      format: "ops",
      exprs: [
        {expr: 'rate(node_disk_writes_completed{device="'+desc["disk"]+'",instance="$target"}[1m])', title: "Write", color: "#9F1B00"},
        {expr: 'rate(node_disk_reads_completed{device="'+desc["disk"]+'",instance="$target"}[1m])', title: "Read", color: "#7EB26D"},
      ]
    }))))

  elsif desc["type"] == "cpu"
    gen_panel_custom(id, datasource_id, desc.merge(JSON.load(JSON.dump({
      title: desc["target"] + " - cpu",
      fill: 0,
      max: 100,
      exprs: [
        {expr: '100 - (avg by (instance)(rate(node_cpu{instance="$target",mode="idle"}[1m]))*100)', title: "Usage", color: "#967302"},
      ]
    }))))

  elsif desc["type"] == "network"
    raise "missed 'interface' field for network panel" unless desc["interface"]
    gen_panel_custom(id, datasource_id, desc.merge(JSON.load(JSON.dump({
      title: desc["target"] + " - #{desc["interface"]}",
      fill: 8,
      format: "Bps",
      stack: true,
      exprs: [
        {expr: 'rate(node_network_receive_bytes{device="'+desc["interface"]+'",instance="$target"}[1m])', title: "In", color: "#0A437C"},
        {expr: 'rate(node_network_transmit_bytes{device="'+desc["interface"]+'",instance="$target"}[1m])', title: "Out", color: "#629E51"},
      ]
    }))))

  elsif desc["type"] == "custom"
    gen_panel_custom(id, datasource_id, desc)

  elsif desc["type"] == "alert"
    if desc["expr"].index(">")
      expr = desc["expr"].split(">", 2)[0].gsub("$target", desc["target"] || "missed_target")
      limit = desc["expr"].split(">", 2)[1].to_f
      operator = "gt"
    elsif desc["expr"].index("<")
      expr = desc["expr"].split("<", 2)[0].gsub("$target", desc["target"] || "missed_target")
      limit = desc["expr"].split("<", 2)[1].to_f
      operator = "lt"
    else
      raise "wrong expr"
    end

    notification = desc.delete('notification')
    if notification
      notif_id = @notif_ids[notification]
    end
    gen_panel_custom(id, datasource_id, desc.merge(JSON.load(JSON.dump({
      exprs: [{expr: expr, title: nil}],
      fill: 0,
      thresholds: [{value: limit, op: operator, fill: true, line: true, colorMode: "critical"}],
      legend: false,
      alert: {
        conditions: [
          {
            evaluator: {params: [limit], type: operator},
            operator: {type: "and"},
            query: {
              datasourceId: datasource_id,
              model: {expr: expr, refId: "A", intervalFactor: 2, step: 4 },
              params: ["A", "1m", "now"]
            },
            reducer: {params: [], type: "avg"},
            type: "query"
          }
        ],
        executionErrorState: "alerting",
        frequency: "30s",
        name: desc["expr"],
        notifications: (notif_id ? [{id: notif_id}] : [])
      }
    }))))

  else
    raise "NIY. unknown panel type `#{desc["type"]}`"
  end
end
gen_panel_custom(id, datasource_id, desc) click to toggle source
# File lib/grafana-rb/cli.rb, line 212
def gen_panel_custom(id, datasource_id, desc)
  queries = ("A".."Z").to_a
  i = -1
  exprs = desc.key?("exprs") ? desc["exprs"] : [{"expr" => desc["expr"]}]
  {
    datasource: @datasource_ids.invert[datasource_id],
    id: id,
    span: desc["span"] || 2,
    stack: desc["stack"],
    targets: exprs.map { |e| 
      q = queries[i += 1]
      {
        expr: e["expr"].gsub("$target", desc["target"] || "missed_target"),
        refId: q,
        intervalFactor: 2,
        step: 4,
        legendFormat: e.key?("title") ? e["title"] : nil
      }
    },
    linewidth: desc["linewidth"] || 2,
    fill: desc["fill"] || 0,
    title: desc["title"] || "title",
    type: "graph",
    yaxes: [{format: desc["format"] || "short", show: true, min: desc["min"] || 0, max: desc["max"]}, {format: "short", show: false}],
    legend: {show: (desc["legend"].nil? ? false : desc["legend"])},
    aliasColors: exprs.select { |e| e["title"] && e["color"] }.map { |e| [e["title"], e["color"]] }.to_h,
  }.merge(desc.key?("alert") ? {alert: desc["alert"], thresholds: desc["thresholds"]} : {})
end
grafana_password() click to toggle source
# File lib/grafana-rb/cli.rb, line 163
def grafana_password
  config["grafana_password"] || DEFAULT_GRAFANA_PASSWORD
end
grafana_user() click to toggle source
# File lib/grafana-rb/cli.rb, line 159
def grafana_user
  config["grafana_user"] || DEFAULT_GRAFANA_USER
end
hex_to_bin(s) click to toggle source
# File lib/grafana-rb/cli.rb, line 54
def hex_to_bin(s)
  s.scan(/../).map { |x| x.hex }.pack('c*')
end
read_yaml(file) click to toggle source
# File lib/grafana-rb/cli.rb, line 140
def read_yaml(file)
  unvault = proc { |o|
    if o.is_a?(Array)
      o.map { |i| unvault.call(i) }
    elsif o.is_a?(Hash)
      o.map { |k, v| [k, unvault.call(v)] }.to_h
    elsif o.is_a?(String) && o.index(MARKER) == 0
      decrypt(o)
    else
      o
    end
  }
  unvault.call(YAML.load(File.read(file)))
end
remove_obsole_dashboards() click to toggle source
# File lib/grafana-rb/cli.rb, line 392
def remove_obsole_dashboards
  names = dashboards.map { |config| config["name"] }
  request(:get, "/api/search/?tag=#{TAG}").reject { |s| names.index(s["title"]) }.each do |s|
    puts "remove dashboard '#{s["title"]}'"
    request(:delete, "/api/dashboards/#{s["uri"]}")
  end
end
request(method, path, params = {}) click to toggle source
# File lib/grafana-rb/cli.rb, line 82
def request(method, path, params = {})
  uri = URI(config["grafana_url"] + path)
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = uri.scheme == "https"
  # http.set_debug_output $stderr
  if method == :get
    req = Net::HTTP::Get.new(uri)
  else 
    if method == :post
      req = Net::HTTP::Post.new(uri)
    elsif method == :patch
      req = Net::HTTP::Patch.new(uri)
    elsif method == :put
      req = Net::HTTP::Put.new(uri)
    elsif method == :delete
      req = Net::HTTP::Delete.new(uri)
    else
      raise "unknown method #{method}"
    end
    req["Content-Type"] = "application/json"
    req.body = params.to_json
  end
  req.basic_auth grafana_user, grafana_password
  body = http.request(req).body
  begin
    JSON.load(body)
  rescue
    body
  end
end
require_files() click to toggle source
# File lib/grafana-rb/cli.rb, line 167
def require_files
  @require_files ||= (config["require"] || REQUIRE_DEFAULT_TEMPLATE).flat_map { |template|
    Dir[File.join(@workdir, template)]
  }.map { |path| File.expand_path(path) } - [File.expand_path(config_file)]
end
usage() click to toggle source
# File lib/grafana-rb/cli.rb, line 428
def usage
  puts "Grafana-rb: utility to configure grafana dashboards as a code"
  puts "Version: #{VERSION}"
  puts ""
  puts "Usage:"
  puts "  grafana-rb [work-dir] [options] <cmd>"
  puts ""
  puts "Options:"
  puts "  --vault <vault-file>    - vault file (<pwd>/vault.key by default)"
  puts ""
  puts "Commands:"
  puts "  apply             - update dashboards"
  puts "  encrypt <string>  - encrypt script using vault-file"
  puts "  decrypt <string>  - decrypt script using vault-file"
  puts ""
end