require 'yaml' require 'json' require 'pp' require 'net/http' require 'openssl' module GrafanaRb class Cli def initialize(argv) @argv = argv @workdir = Dir.pwd @panel_id = 0 end 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 REQUIRE_DEFAULT_TEMPLATE = ["*.yml"] DEFAULT_GRAFANA_USER = "admin" DEFAULT_GRAFANA_PASSWORD = "admin" TAG = "grafana-rb" MARKER = "__VAULT:" def bin_to_hex(s) s.unpack('H*').first end def hex_to_bin(s) s.scan(/../).map { |x| x.hex }.pack('c*') end 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 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 def config_file @config_file ||= File.join(@workdir, "grafana.yml") end 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 def die(msg = nil) if msg puts "FATAL: #{msg}" else puts "exit with status code -1" end exit -1 end 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 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 def config @config ||= read_yaml(config_file) || {} end def grafana_user config["grafana_user"] || DEFAULT_GRAFANA_USER end def grafana_password config["grafana_password"] || DEFAULT_GRAFANA_PASSWORD end 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 def dashboards @dashboards ||= require_files.map { |path| yaml = read_yaml(path) yaml["name"] ||= File.basename(path, File.extname(path)) yaml } end 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 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 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 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 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 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 def deep_clone(hash) JSON.load(JSON.dump(hash)) end 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 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 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] " puts "" puts "Options:" puts " --vault - vault file (/vault.key by default)" puts "" puts "Commands:" puts " apply - update dashboards" puts " encrypt - encrypt script using vault-file" puts " decrypt - decrypt script using vault-file" puts "" end end end