From 3e672bb32b163b697a67a5936703c7dcd17a77dd Mon Sep 17 00:00:00 2001 From: Simon Pasquier Date: Wed, 4 Nov 2015 09:58:29 +0100 Subject: [PATCH 1/2] Add grafana_datasource resource This resource is used to configure datasources in Grafana. For now, it supports InfluxDB 0.9, Graphite, Elasticsearch, Prometheus, KairosDB and OpenTSDB. --- lib/puppet/provider/grafana.rb | 86 ++++++++ .../provider/grafana_datasource/grafana.rb | 192 ++++++++++++++++++ lib/puppet/type/grafana_datasource.rb | 91 +++++++++ .../type/grafana_datasource_type_spec.rb | 38 ++++ 4 files changed, 407 insertions(+) create mode 100644 lib/puppet/provider/grafana.rb create mode 100644 lib/puppet/provider/grafana_datasource/grafana.rb create mode 100644 lib/puppet/type/grafana_datasource.rb create mode 100644 spec/unit/puppet/type/grafana_datasource_type_spec.rb diff --git a/lib/puppet/provider/grafana.rb b/lib/puppet/provider/grafana.rb new file mode 100644 index 000000000..b28a84913 --- /dev/null +++ b/lib/puppet/provider/grafana.rb @@ -0,0 +1,86 @@ +# Copyright 2015 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +require 'cgi' +require 'json' +require 'net/http' + +class Puppet::Provider::Grafana < Puppet::Provider + # Helper methods + def grafana_host + unless @grafana_host + @grafana_host = URI.parse(resource[:grafana_url]).host + end + @grafana_host + end + + def grafana_port + unless @grafana_port + @grafana_port = URI.parse(resource[:grafana_url]).port + end + @grafana_port + end + + def grafana_scheme + unless @grafana_scheme + @grafana_scheme = URI.parse(resource[:grafana_url]).scheme + end + @grafana_scheme + end + + # Return a Net::HTTP::Response object + def send_request(operation="GET", path="", data=nil, search_path={}) + request = nil + encoded_search = "" + + if URI.respond_to?(:encode_www_form) + encoded_search = URI.encode_www_form(search_path) + else + # Ideally we would have use URI.encode_www_form but it isn't + # available with Ruby 1.8.x that ships with CentOS 6.5. + encoded_search = search_path.to_a.map do |x| + x.map{|y| CGI.escape(y.to_s)}.join('=') + end + encoded_search = encoded_search.join('&') + end + uri = URI.parse("%s://%s:%d%s?%s" % [ + self.grafana_scheme, self.grafana_host, self.grafana_port, + path, encoded_search]) + + case operation.upcase + when 'POST' + request = Net::HTTP::Post.new(uri.request_uri) + request.body = data.to_json() + when 'PUT' + request = Net::HTTP::Put.new(uri.request_uri) + request.body = data.to_json() + when 'GET' + request = Net::HTTP::Get.new(uri.request_uri) + when 'DELETE' + request = Net::HTTP::Delete.new(uri.request_uri) + else + raise Puppet::Error, "Unsupported HTTP operation '%s'" % operation + end + + request.content_type = 'application/json' + if resource[:grafana_user] and resource[:grafana_user] + request.basic_auth resource[:grafana_user], resource[:grafana_password] + end + + return Net::HTTP.start(self.grafana_host, self.grafana_port) do |http| + http.request(request) + end + end +end + diff --git a/lib/puppet/provider/grafana_datasource/grafana.rb b/lib/puppet/provider/grafana_datasource/grafana.rb new file mode 100644 index 000000000..1e08b8805 --- /dev/null +++ b/lib/puppet/provider/grafana_datasource/grafana.rb @@ -0,0 +1,192 @@ +# Copyright 2015 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +require 'json' + +require File.expand_path(File.join(File.dirname(__FILE__), '..', 'grafana')) + +Puppet::Type.type(:grafana_datasource).provide(:grafana, :parent => Puppet::Provider::Grafana) do + desc "Support for Grafana datasources" + + defaultfor :kernel => 'Linux' + + def datasources + response = self.send_request('GET', '/api/datasources') + if response.code != '200' + fail("Fail to retrieve datasources (HTTP response: %s/%s)" % + [response.code, response.body]) + end + + begin + datasources = JSON.parse(response.body) + + datasources.collect{|x| x["id"]}.collect do |id| + response = self.send_request('GET', '/api/datasources/%s' % id) + if response.code != '200' + fail("Fail to retrieve datasource %d (HTTP response: %s/%s)" % + [id, response.code, response.body]) + end + + datasource = JSON.parse(response.body) + + { + :id => datasource["id"], + :name => datasource["name"], + :url => datasource["url"], + :type => datasource["type"], + :user => datasource["user"], + :password => datasource["password"], + :database => datasource["database"], + :access_mode => datasource["access"], + :is_default => datasource["isDefault"] ? :true : :false, + :json_data => datasource["jsonData"] + } + end + rescue JSON::ParserError + fail("Fail to parse response: %s" % response.body) + end + end + + def datasource + unless @datasource + @datasource = self.datasources.find { |x| x[:name] == resource[:name] } + end + @datasource + end + + def datasource=(value) + @datasource = value + end + + def type + self.datasource[:type] + end + + def type=(value) + resource[:type] = value + self.save_datasource() + end + + def url + self.datasource[:url] + end + + def url=(value) + resource[:url] = value + self.save_datasource() + end + + def access_mode + self.datasource[:access_mode] + end + + def access_mode=(value) + self.resource[:access_mode] = value + self.save_datasource() + end + + def database + self.datasource[:database] + end + + def database=(value) + resource[:database] = value + self.save_datasource() + end + + def user + self.datasource[:user] + end + + def user=(value) + resource[:user] = value + self.save_datasource() + end + + def password + self.datasource[:password] + end + + def password=(value) + resource[:password] = value + self.save_datasource() + end + + def is_default + self.datasource[:is_default] + end + + def is_default=(value) + resource[:is_default] = value + self.save_datasource() + end + + def json_data + self.datasource[:json_data] + end + + def json_data=(value) + resource[:json_data] = value + self.save_datasource() + end + + def save_datasource + data = { + :name => resource[:name], + :type => resource[:type], + :url => resource[:url], + :access => resource[:access_mode], + :database => resource[:database], + :user => resource[:user], + :password => resource[:password], + :isDefault => (resource[:is_default] == :true), + :jsonData => resource[:json_data], + } + + if self.datasource.nil? + response = self.send_request('POST', '/api/datasources', data) + else + data[:id] = self.datasource[:id] + response = self.send_request('PUT', '/api/datasources/%s' % self.datasource[:id], data) + end + + if response.code != '200' + fail("Failed to create save '%s' (HTTP response: %s/'%s')" % + [resource[:name], response.code, response.body]) + end + self.datasource = nil + end + + def delete_datasource + response = self.send_request('DELETE', '/api/datasources/%s' % self.datasource[:id]) + + if response.code != '200' + fail("Failed to delete datasource '%s' (HTTP response: %s/'%s')" % + [resource[:name], response.code, response.body]) + end + self.datasource = nil + end + + def create + self.save_datasource() + end + + def destroy + self.delete_datasource() + end + + def exists? + self.datasource + end +end diff --git a/lib/puppet/type/grafana_datasource.rb b/lib/puppet/type/grafana_datasource.rb new file mode 100644 index 000000000..c16ba85b4 --- /dev/null +++ b/lib/puppet/type/grafana_datasource.rb @@ -0,0 +1,91 @@ +# Copyright 2015 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +Puppet::Type.newtype(:grafana_datasource) do + @doc = "Manage datasources in Grafana" + + ensurable + + newparam(:name, :namevar => true) do + desc "The name of the datasource." + end + + newparam(:grafana_url) do + desc "The URL of the Grafana server" + defaultto "" + + validate do |value| + unless value =~ /^https?:\/\// + raise ArgumentError , "'%s' is not a valid URL" % value + end + end + end + + newparam(:grafana_user) do + desc "The username for the Grafana server" + end + + newparam(:grafana_password) do + desc "The password for the Grafana server" + end + + newproperty(:url) do + desc "The URL of the datasource" + + validate do |value| + unless value =~ /^https?:\/\// + raise ArgumentError , "'%s' is not a valid URL" % value + end + end + end + + newproperty(:type) do + desc "The datasource type" + newvalues(:influxdb, :elasticsearch, :graphite, :kairosdb, :opentsdb, :prometheus) + end + + newproperty(:user) do + desc "The username for the datasource (optional)" + end + + newproperty(:password) do + desc "The password for the datasource (optional)" + end + + newproperty(:database) do + desc "The name of the database (optional)" + end + + newproperty(:access_mode) do + desc "Whether the datasource is accessed directly or not by the clients" + newvalues(:direct, :proxy) + defaultto :direct + end + + newproperty(:is_default) do + desc "Whether the datasource is the default one" + newvalues(:true, :false) + defaultto :false + end + + newproperty(:json_data) do + desc "Additional JSON data to configure the datasource (optional)" + + validate do |value| + unless value.nil? or value.is_a?(Hash) then + raise ArgumentError , "json_data should be a Hash!" + end + end + end +end diff --git a/spec/unit/puppet/type/grafana_datasource_type_spec.rb b/spec/unit/puppet/type/grafana_datasource_type_spec.rb new file mode 100644 index 000000000..b7cd78020 --- /dev/null +++ b/spec/unit/puppet/type/grafana_datasource_type_spec.rb @@ -0,0 +1,38 @@ +# Copyright 2015 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +require 'spec_helper' + +describe Puppet::Type.type(:grafana_datasource) do + context "when setting parameters" do + + it "should fail if grafana_url isn't HTTP-based" do + expect { + described_class.new :name => "foo", :grafana_url => "example.com", :content => "{}", :ensure => :present + }.to raise_error(Puppet::Error, /not a valid URL/) + end + + it "should fail if json_data isn't valid" do + expect { + described_class.new :name => "foo", :grafana_url => "http://example.com", :json_data => "invalid", :ensure => :present + }.to raise_error(Puppet::Error, /json_data should be a Hash/) + end + + it "should accept valid parameters" do + resource = described_class.new :name => "foo", :grafana_url => "http://example.com", :url => 'http://influx.example.com' + expect(resource[:name]).to eq('foo') + expect(resource[:grafana_url]).to eq('http://example.com') + expect(resource[:url]).to eq('http://influx.example.com') + end + end +end From 561a955d0863afc0c074cb8c4da51751d2480ffb Mon Sep 17 00:00:00 2001 From: Simon Pasquier Date: Mon, 7 Mar 2016 14:01:03 +0100 Subject: [PATCH 2/2] Add grafana_dashboard resource (fixes #72) This resource is used to manage dashboards in Grafana. --- .../provider/grafana_dashboard/grafana.rb | 109 ++++++++++++++++++ lib/puppet/type/grafana_dashboard.rb | 75 ++++++++++++ .../type/grafana_dashboard_type_spec.rb | 44 +++++++ 3 files changed, 228 insertions(+) create mode 100644 lib/puppet/provider/grafana_dashboard/grafana.rb create mode 100644 lib/puppet/type/grafana_dashboard.rb create mode 100644 spec/unit/puppet/type/grafana_dashboard_type_spec.rb diff --git a/lib/puppet/provider/grafana_dashboard/grafana.rb b/lib/puppet/provider/grafana_dashboard/grafana.rb new file mode 100644 index 000000000..30599b2fe --- /dev/null +++ b/lib/puppet/provider/grafana_dashboard/grafana.rb @@ -0,0 +1,109 @@ +# Copyright 2015 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +require 'json' + +require File.expand_path(File.join(File.dirname(__FILE__), '..', 'grafana')) + +# Note: this class doesn't implement the self.instances and self.prefetch +# methods because the Grafana API doesn't allow to retrieve the dashboards and +# all their properties in a single call. +Puppet::Type.type(:grafana_dashboard).provide(:grafana, :parent => Puppet::Provider::Grafana) do + desc "Support for Grafana dashboards stored into Grafana" + + defaultfor :kernel => 'Linux' + + # Return the list of dashboards + def dashboards + response = self.send_request('GET', '/api/search', nil, {:q => '', :starred => false}) + if response.code != '200' + fail("Fail to retrieve the dashboards (HTTP response: %s/%s)" % + [response.code, response.body]) + end + + begin + JSON.parse(response.body) + rescue JSON::ParserError + fail("Fail to parse dashboards (HTTP response: %s/%s)" % + [response_code, response.body]) + end + end + + # Return the dashboard matching with the resource's title + def find_dashboard + if not self.dashboards.find{ |x| x['title'] == resource[:title] } + return + end + + response = self.send_request('GET', '/api/dashboards/db/%s' % self.slug) + if response.code != '200' + fail("Fail to retrieve dashboard '%s' (HTTP response: %s/%s)" % + [resource[:title], response.code, response.body]) + end + + begin + # Cache the dashboard's content + @dashboard = JSON.parse(response.body)["dashboard"] + rescue JSON::ParserError + fail("Fail to parse dashboard '%s': %s" % + [resource[:title], response.body]) + end + end + + def save_dashboard(dashboard) + data = { + :dashboard => dashboard.merge({ + 'title' => resource[:title], + 'id' => @dashboard ? @dashboard['id'] : nil, + 'version' => @dashboard ? @dashboard['version'] + 1 : 0 + }), + :overwrite => @dashboard != nil + } + + response = self.send_request('POST', '/api/dashboards/db', data) + if response.code != '200' + fail("Fail to save dashboard '%s' (HTTP response: %s/%s)" % + [resource[:name], response.code, response.body]) + end + end + + def slug + resource[:title].downcase.gsub(/[^\w\- ]/, '').gsub(/ +/, '-') + end + + def content + @dashboard + end + + def content=(value) + self.save_dashboard(value) + end + + def create + self.save_dashboard(resource[:content]) + end + + def destroy + response = self.send_request('DELETE', '/api/dashboards/db/%s' % self.slug) + + if response.code != '200' + raise Puppet::Error, "Failed to delete dashboard '%s' (HTTP "\ + "response: %s/'%s')" % [resource[:title], response.code, + response.body] + end + end + + def exists? + self.find_dashboard() + end +end diff --git a/lib/puppet/type/grafana_dashboard.rb b/lib/puppet/type/grafana_dashboard.rb new file mode 100644 index 000000000..d1dcdac5f --- /dev/null +++ b/lib/puppet/type/grafana_dashboard.rb @@ -0,0 +1,75 @@ +# Copyright 2015 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +require 'json' + +Puppet::Type.newtype(:grafana_dashboard) do + @doc = "Manage dashboards in Grafana" + + ensurable + + newparam(:title, :namevar => true) do + desc "The title of the dashboard." + end + + newproperty(:content) do + desc "The JSON representation of the dashboard." + + validate do |value| + begin + JSON.parse(value) + rescue JSON::ParserError + raise ArgumentError , "Invalid JSON string for content" + end + end + + munge do |value| + JSON.parse(value) + end + + def should_to_s(value) + if value.length > 12 + "#{value.to_s.slice(0,12)}..." + else + value + end + end + + def is_to_s(value) + should_to_s(value) + end + end + + newparam(:grafana_url) do + desc "The URL of the Grafana server" + defaultto "" + + validate do |value| + unless value =~ /^https?:\/\// + raise ArgumentError , "'%s' is not a valid URL" % value + end + end + end + + newparam(:grafana_user) do + desc "The username for the Grafana server (optional)" + end + + newparam(:grafana_password) do + desc "The password for the Grafana server (optional)" + end + + validate do + fail('content is required when ensure is present') if self[:ensure] == :present and self[:content].nil? + end +end diff --git a/spec/unit/puppet/type/grafana_dashboard_type_spec.rb b/spec/unit/puppet/type/grafana_dashboard_type_spec.rb new file mode 100644 index 000000000..b6db7774d --- /dev/null +++ b/spec/unit/puppet/type/grafana_dashboard_type_spec.rb @@ -0,0 +1,44 @@ +# Copyright 2015 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +require 'spec_helper' + +describe Puppet::Type.type(:grafana_dashboard) do + context "when setting parameters" do + + it "should fail if grafana_url isn't HTTP-based" do + expect { + described_class.new :name => "foo", :grafana_url => "example.com", :content => "{}", :ensure => :present + }.to raise_error(Puppet::Error, /not a valid URL/) + end + + it "should fail if content isn't provided" do + expect { + described_class.new :name => "foo", :grafana_url => "http://example.com", :ensure => :present + }.to raise_error(Puppet::Error, /content is required/) + end + + it "should fail if content isn't JSON" do + expect { + described_class.new :name => "foo", :grafana_url => "http://example.com/", :content => "{invalid", :ensure => :present + }.to raise_error(Puppet::Error, /Invalid JSON/) + end + + it "should accept valid parameters" do + resource = described_class.new :name => "foo", :grafana_url => "http://example.com/", :content => "{}", :ensure => :present + expect(resource[:name]).to eq('foo') + expect(resource[:grafana_url]).to eq('http://example.com/') + expect(resource[:content]).to eq({}) + end + end +end