diff --git a/lib/puppet/provider/grafana_conn_validator/net_http.rb b/lib/puppet/provider/grafana_conn_validator/net_http.rb new file mode 100644 index 0000000..7799e84 --- /dev/null +++ b/lib/puppet/provider/grafana_conn_validator/net_http.rb @@ -0,0 +1,63 @@ +# In this case I'm trying the relative path first, then falling back to normal +# mechanisms. This should be fixed in future versions of puppet but it looks +# like we'll need to maintain this for some time perhaps. +$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', '..', '..')) +require 'puppet/util/grafana_conn_validator' + +# This file contains a provider for the resource type `grafana_conn_validator`, +# which validates the Grafana API connection by attempting an http(s) connection. + +Puppet::Type.type(:grafana_conn_validator).provide(:net_http) do + desc "A provider for the resource type `grafana_conn_validator`, + which validates the Grafana API connection by attempting an http(s) + connection to the Grafana server." + + # Test to see if the resource exists, returns true if it does, false if it + # does not. + # + # Here we simply monopolize the resource API, to execute a test to see if the + # database is connectable. When we return a state of `false` it triggers the + # create method where we can return an error message. + # + # @return [bool] did the test succeed? + def exists? + start_time = Time.now + timeout = resource[:timeout] + + success = validator.attempt_connection + + while success == false && ((Time.now - start_time) < timeout) + # It can take several seconds for the Grafana server to start up; + # especially on the first install. Therefore, our first connection attempt + # may fail. Here we have somewhat arbitrarily chosen to retry every 2 + # seconds until the configurable timeout has expired. + Puppet.notice('Failed to connect to Grafana API; sleeping 2 seconds before retry') + sleep 2 + success = validator.attempt_connection + end + + unless success + Puppet.notice("Failed to connect to Grafana within timeout window of #{timeout} seconds; giving up.") + end + + success + end + + # This method is called when the exists? method returns false. + # + # @return [void] + def create + # If `#create` is called, that means that `#exists?` returned false, which + # means that the connection could not be established... so we need to + # cause a failure here. + raise Puppet::Error, "Unable to connect to Grafana server! (#{@validator.grafana_url})" + end + + # Returns the existing validator, if one exists otherwise creates a new object + # from the class. + # + # @api private + def validator + @validator ||= Puppet::Util::GrafanaConnValidator.new(resource[:grafana_url], resource[:grafana_api_path]) + end +end diff --git a/lib/puppet/type/grafana_conn_validator.rb b/lib/puppet/type/grafana_conn_validator.rb new file mode 100644 index 0000000..7c659ed --- /dev/null +++ b/lib/puppet/type/grafana_conn_validator.rb @@ -0,0 +1,42 @@ +Puppet::Type.newtype(:grafana_conn_validator) do + desc <<-DESC + Verify connectivity to the Grafana API + DESC + + ensurable + + newparam(:name, namevar: true) do + desc 'Arbitrary name of this resource' + end + + newparam(:grafana_url) do + desc 'The URL of the Grafana server' + defaultto 'http://localhost:3000' + + validate do |value| + unless value =~ %r{^https?://} + raise ArgumentError, format('%s is not a valid URL', value) + end + end + end + + newparam(:grafana_api_path) do + desc 'The absolute path to the API endpoint' + defaultto '/api/health' + + validate do |value| + unless value =~ %r{^/.*/?api/.*$} + raise ArgumentError, format('%s is not a valid API path', value) + end + end + end + + newparam(:timeout) do + desc 'How long to wait for the API to be available' + defaultto(20) + end + + autorequire(:service) do + 'grafana-server' + end +end diff --git a/lib/puppet/type/grafana_dashboard.rb b/lib/puppet/type/grafana_dashboard.rb index 967669e..b6cd648 100644 --- a/lib/puppet/type/grafana_dashboard.rb +++ b/lib/puppet/type/grafana_dashboard.rb @@ -1,85 +1,89 @@ # Copyright 2015 Mirantis, Inc. # 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 newparam(:folder) do desc 'The folder to place the dashboard in (optional)' 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| new_value = JSON.parse(value).reject { |k, _| k =~ %r{^id|version|title$} } new_value.sort.to_h end def should_to_s(value) if value.length > 12 "#{value.to_s.slice(0, 12)}..." else value end end end newparam(:grafana_url) do desc 'The URL of the Grafana server' defaultto '' validate do |value| unless value =~ %r{^https?://} raise ArgumentError, format('%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 newparam(:grafana_api_path) do desc 'The absolute path to the API endpoint' defaultto '/api' validate do |value| unless value =~ %r{^/.*/?api$} raise ArgumentError, format('%s is not a valid API path', value) end end end newparam(:organization) do desc 'The organization name to create the datasource on' defaultto 1 end # rubocop:disable Style/SignalException validate do fail('content is required when ensure is present') if self[:ensure] == :present && self[:content].nil? end autorequire(:service) do 'grafana-server' end + + autorequire(:grafana_conn_validator) do + 'grafana' + end end diff --git a/lib/puppet/type/grafana_datasource.rb b/lib/puppet/type/grafana_datasource.rb index 79ce38a..c63c66f 100644 --- a/lib/puppet/type/grafana_datasource.rb +++ b/lib/puppet/type/grafana_datasource.rb @@ -1,130 +1,134 @@ # Copyright 2015 Mirantis, Inc. # 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_api_path) do desc 'The absolute path to the API endpoint' defaultto '/api' validate do |value| unless value =~ %r{^/.*/?api$} raise ArgumentError, format('%s is not a valid API path', value) end end end newparam(:grafana_url) do desc 'The URL of the Grafana server' defaultto '' validate do |value| unless value =~ %r{^https?://} raise ArgumentError, format('%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/Endpoint of the datasource' end newproperty(:type) do desc 'The datasource type' end newparam(:organization) do desc 'The organization name to create the datasource on' defaultto 1 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(:basic_auth) do desc 'Whether basic auth is enabled or not' newvalues(:true, :false) defaultto :false end newproperty(:basic_auth_user) do desc 'The username for basic auth if enabled' defaultto '' end newproperty(:basic_auth_password) do desc 'The password for basic auth if enabled' defaultto '' end newproperty(:with_credentials) do desc 'Whether credentials such as cookies or auth headers should be sent with cross-site requests' 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? || value.is_a?(Hash) raise ArgumentError, 'json_data should be a Hash!' end end end newproperty(:secure_json_data) do desc 'Additional secure JSON data to configure the datasource (optional)' validate do |value| unless value.nil? || value.is_a?(Hash) raise ArgumentError, 'secure_json_data should be a Hash!' end end end def set_sensitive_parameters(sensitive_parameters) # rubocop:disable Style/AccessorMethodName parameter(:password).sensitive = true if parameter(:password) parameter(:basic_auth_password).sensitive = true if parameter(:basic_auth_password) super(sensitive_parameters) end autorequire(:service) do 'grafana-server' end + + autorequire(:grafana_conn_validator) do + 'grafana' + end end diff --git a/lib/puppet/type/grafana_folder.rb b/lib/puppet/type/grafana_folder.rb index 396e736..e8325a9 100644 --- a/lib/puppet/type/grafana_folder.rb +++ b/lib/puppet/type/grafana_folder.rb @@ -1,58 +1,62 @@ require 'json' Puppet::Type.newtype(:grafana_folder) do @doc = 'Manage folders in Grafana' ensurable newparam(:title, namevar: true) do desc 'The title of the folder' end newparam(:uid) do desc 'UID of the folder' end newparam(:grafana_url) do desc 'The URL of the Grafana server' defaultto '' validate do |value| unless value =~ %r{^https?://} raise ArgumentError, format('%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 newparam(:grafana_api_path) do desc 'The absolute path to the API endpoint' defaultto '/api' validate do |value| unless value =~ %r{^/.*/?api$} raise ArgumentError, format('%s is not a valid API path', value) end end end newparam(:organization) do desc 'The organization name to create the folder on' defaultto 1 end newproperty(:permissions, array_matching: :all) do desc 'The permissions of the folder' end autorequire(:service) do 'grafana-server' end + + autorequire(:grafana_conn_validator) do + 'grafana' + end end diff --git a/lib/puppet/type/grafana_notification.rb b/lib/puppet/type/grafana_notification.rb index e3284d0..9cad69f 100644 --- a/lib/puppet/type/grafana_notification.rb +++ b/lib/puppet/type/grafana_notification.rb @@ -1,75 +1,79 @@ # Copyright 2015 Mirantis, Inc. # Puppet::Type.newtype(:grafana_notification) do @doc = 'Manage notification in Grafana' ensurable newparam(:name, namevar: true) do desc 'The name of the notification.' end newparam(:grafana_api_path) do desc 'The absolute path to the API endpoint' defaultto '/api' validate do |value| unless value =~ %r{^/.*/?api$} raise ArgumentError, format('%s is not a valid API path', value) end end end newparam(:grafana_url) do desc 'The URL of the Grafana server' defaultto '' validate do |value| unless value =~ %r{^https?://} raise ArgumentError, format('%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(:type) do desc 'The notification type' end newproperty(:is_default) do desc 'Whether the notification is the default one' newvalues(:true, :false) defaultto :false end newproperty(:send_reminder) do desc 'Whether automatic message resending is enabled or not' newvalues(:true, :false) defaultto :false end newproperty(:frequency) do desc 'The notification reminder frequency' end newproperty(:settings) do desc 'Additional JSON data to configure the notification' validate do |value| unless value.nil? || value.is_a?(Hash) raise ArgumentError, 'settings should be a Hash!' end end end autorequire(:service) do 'grafana-server' end + + autorequire(:grafana_conn_validator) do + 'grafana' + end end diff --git a/lib/puppet/type/grafana_organization.rb b/lib/puppet/type/grafana_organization.rb index 01a521a..42ac653 100644 --- a/lib/puppet/type/grafana_organization.rb +++ b/lib/puppet/type/grafana_organization.rb @@ -1,59 +1,63 @@ Puppet::Type.newtype(:grafana_organization) do @doc = 'Manage organizations in Grafana' ensurable do defaultvalues defaultto :present end newparam(:name, namevar: true) do desc 'The name of the organization.' end newparam(:grafana_api_path) do desc 'The absolute path to the API endpoint' defaultto '/api' validate do |value| unless value =~ %r{^/.*/?api$} raise ArgumentError, format('%s is not a valid API path', value) end end end newparam(:grafana_url) do desc 'The URL of the Grafana server' defaultto '' validate do |value| unless value =~ %r{^https?://} raise ArgumentError, format('%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(:id) do desc 'The ID of the organization' end newproperty(:address) do desc 'Additional JSON data to configure the organization address (optional)' validate do |value| unless value.nil? || value.is_a?(Hash) raise ArgumentError, 'address should be a Hash!' end end end autorequire(:service) do 'grafana-server' end + + autorequire(:grafana_conn_validator) do + 'grafana' + end end diff --git a/lib/puppet/type/grafana_user.rb b/lib/puppet/type/grafana_user.rb index 1be077b..53beb6c 100644 --- a/lib/puppet/type/grafana_user.rb +++ b/lib/puppet/type/grafana_user.rb @@ -1,73 +1,77 @@ Puppet::Type.newtype(:grafana_user) do @doc = 'Manage users in Grafana' ensurable newparam(:name, namevar: true) do desc 'The username of the user.' end newparam(:grafana_api_path) do desc 'The absolute path to the API endpoint' defaultto '/api' validate do |value| unless value =~ %r{^/.*/?api$} raise ArgumentError, format('%s is not a valid API path', value) end end end newparam(:grafana_url) do desc 'The URL of the Grafana server' defaultto '' validate do |value| unless value =~ %r{^https?://} raise ArgumentError, format('%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 newparam(:full_name) do desc 'The full name of the user.' end newproperty(:password) do desc 'The password for the user' def insync?(_is) provider.check_password end end newproperty(:email) do desc 'The email for the user' end newproperty(:theme) do desc 'The theme for the user' end newproperty(:is_admin) do desc 'Whether the user is a grafana admin' newvalues(:true, :false) defaultto :false end def set_sensitive_parameters(sensitive_parameters) # rubocop:disable Style/AccessorMethodName parameter(:password).sensitive = true if parameter(:password) super(sensitive_parameters) end autorequire(:service) do 'grafana-server' end + + autorequire(:grafana_conn_validator) do + 'grafana' + end end diff --git a/lib/puppet/util/grafana_conn_validator.rb b/lib/puppet/util/grafana_conn_validator.rb new file mode 100644 index 0000000..2f8e97e --- /dev/null +++ b/lib/puppet/util/grafana_conn_validator.rb @@ -0,0 +1,45 @@ +require 'net/http' + +module Puppet + module Util + # Validator class, for testing that Grafana is alive + class GrafanaConnValidator + attr_reader :grafana_url + attr_reader :grafana_api_path + + def initialize(grafana_url, grafana_api_path) + @grafana_url = grafana_url + @grafana_api_path = grafana_api_path + end + + # Utility method; attempts to make an http/https connection to the Grafana server. + # This is abstracted out into a method so that it can be called multiple times + # for retry attempts. + # + # @return true if the connection is successful, false otherwise. + def attempt_connection + # All that we care about is that we are able to connect successfully via + # http(s), so here we're simpling hitting a somewhat arbitrary low-impact URL + # on the Grafana server. + grafana_host = URI.parse(@grafana_url).host + grafana_port = URI.parse(@grafana_url).port + grafana_scheme = URI.parse(@grafana_url).scheme + http = Net::HTTP.new(grafana_host, grafana_port) + http.use_ssl = (grafana_scheme == 'https') + http.verify_mode = OpenSSL::SSL::VERIFY_NONE + request = Net::HTTP::Get.new(@grafana_api_path) + request.add_field('Accept', 'application/json') + response = http.request(request) + + unless response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPUnauthorized) + Puppet.notice "Unable to connect to Grafana server (#{grafana_scheme}://#{grafana_host}:#{grafana_port}): [#{response.code}] #{response.msg}" + return false + end + return true + rescue Exception => e # rubocop:disable Lint/RescueException + Puppet.notice "Unable to connect to Grafana server (#{grafana_scheme}://#{grafana_host}:#{grafana_port}): #{e.message}" + return false + end + end + end +end diff --git a/manifests/validator.pp b/manifests/validator.pp new file mode 100644 index 0000000..399595d --- /dev/null +++ b/manifests/validator.pp @@ -0,0 +1,17 @@ +# @summary Manage grafana_conn_validator resource +# +# @param grafana_url +# Grafana URL. +# @param grafana_api_path +# API path to validate with. +# +class grafana::validator ( + Stdlib::HTTPUrl $grafana_url = 'http://localhost:3000', + Stdlib::Absolutepath $grafana_api_path = '/api/health', +) { + + grafana_conn_validator { 'grafana': + grafana_url => $grafana_url, + grafana_api_path => $grafana_api_path, + } +} diff --git a/spec/acceptance/grafana_folder_spec.rb b/spec/acceptance/grafana_folder_spec.rb index b5b0746..250c2b1 100644 --- a/spec/acceptance/grafana_folder_spec.rb +++ b/spec/acceptance/grafana_folder_spec.rb @@ -1,193 +1,196 @@ require 'spec_helper_acceptance' describe 'grafana_folder' do context 'setup grafana server' do it 'runs successfully' do pp = <<-EOS class { 'grafana': cfg => { security => { admin_user => 'admin', admin_password => 'admin' } } } EOS apply_manifest(pp, catch_failures: true) apply_manifest(pp, catch_changes: true) end end context 'create folder resource' do it 'creates the folders' do pp = <<-EOS + include grafana::validator grafana_folder { 'example-folder': ensure => present, uid => 'example-folder', grafana_url => 'http://localhost:3000', grafana_user => 'admin', grafana_password => 'admin', permissions => [ {'permission' => 2, 'role' => 'Editor'}, {'permission' => 1, 'role' => 'Viewer'}, ], } grafana_folder { 'editor-folder': ensure => present, uid => 'editor-folder', grafana_url => 'http://localhost:3000', grafana_user => 'admin', grafana_password => 'admin', permissions => [ {'permission' => 1, 'role' => 'Editor'}, ], } EOS apply_manifest(pp, catch_failures: true) apply_manifest(pp, catch_changes: true) end it 'has created the example folder' do shell('curl --user admin:admin http://localhost:3000/api/folders') do |f| expect(f.stdout).to match(%r{example-folder}) end end it 'has created the editor folder' do shell('curl --user admin:admin http://localhost:3000/api/folders') do |f| expect(f.stdout).to match(%r{editor-folder}) end end it 'has created the example folder permissions' do shell('curl --user admin:admin http://localhost:3000/api/folders/example-folder/permissions') do |f| data = JSON.parse(f.stdout) expect(data).to include(hash_including('permission' => 2, 'role' => 'Editor'), hash_including('permission' => 1, 'role' => 'Viewer')) end end it 'has created the editor folder permissions' do shell('curl --user admin:admin http://localhost:3000/api/folders/editor-folder/permissions') do |f| data = JSON.parse(f.stdout) expect(data).to include(hash_including('permission' => 1, 'role' => 'Editor')) end end end context 'updates folder resource' do it 'updates the folders' do pp = <<-EOS grafana_folder { 'example-folder': ensure => present, uid => 'example-folder', grafana_url => 'http://localhost:3000', grafana_user => 'admin', grafana_password => 'admin', permissions => [ {'permission' => 2, 'role' => 'Editor'}, ], } grafana_folder { 'editor-folder': ensure => present, uid => 'editor-folder', grafana_url => 'http://localhost:3000', grafana_user => 'admin', grafana_password => 'admin', permissions => [ {'permission' => 1, 'role' => 'Viewer'}, ], } EOS apply_manifest(pp, catch_failures: true) apply_manifest(pp, catch_changes: true) end it 'has updated the example folder permissions' do shell('curl --user admin:admin http://localhost:3000/api/folders/example-folder/permissions') do |f| data = JSON.parse(f.stdout) expect(data).to include(hash_including('permission' => 2, 'role' => 'Editor')) # expect(data.size).to eq(1) # expect(data[0]['permission']).to eq(2) # expect(data[0]['role']).to eq('Editor') end end it 'has updated the editor folder permissions' do shell('curl --user admin:admin http://localhost:3000/api/folders/editor-folder/permissions') do |f| data = JSON.parse(f.stdout) expect(data).to include(hash_including('permission' => 1, 'role' => 'Viewer')) end end end context 'create folder containing dashboard' do it 'creates an example dashboard in the example folder' do pp = <<-EOS + include grafana::validator grafana_dashboard { 'example-dashboard': ensure => present, grafana_url => 'http://localhost:3000', grafana_user => 'admin', grafana_password => 'admin', content => '{"uid": "abc123xy"}', folder => 'example-folder' } EOS apply_manifest(pp, catch_failures: true) apply_manifest(pp, catch_changes: true) end it 'folder contains dashboard' do shell('curl --user admin:admin http://localhost:3000/api/dashboards/db/example-dashboard') do |f| expect(f.stdout).to match(%r{"folderId":1}) end end end context 'destroy resources' do it 'destroys the folders and dashboard' do pp = <<-EOS + include grafana::validator grafana_folder { 'example-folder': ensure => absent, grafana_url => 'http://localhost:3000', grafana_user => 'admin', grafana_password => 'admin', } grafana_folder { 'editor-folder': ensure => absent, grafana_url => 'http://localhost:3000', grafana_user => 'admin', grafana_password => 'admin', } grafana_folder { 'nomatch-folder': ensure => absent, grafana_url => 'http://localhost:3000', grafana_user => 'admin', grafana_password => 'admin', } grafana_dashboard { 'example-dashboard': ensure => absent, grafana_url => 'http://localhost:3000', grafana_user => 'admin', grafana_password => 'admin', } EOS apply_manifest(pp, catch_failures: true) apply_manifest(pp, catch_changes: true) end it 'has no example-folder' do shell('curl --user admin:admin http://localhost:3000/api/folders') do |f| expect(f.stdout).not_to match(%r{example-folder}) end end it 'has no editor-folder' do shell('curl --user admin:admin http://localhost:3000/api/folders') do |f| expect(f.stdout).not_to match(%r{editor-folder}) end end it 'has no nomatch-folder' do shell('curl --user admin:admin http://localhost:3000/api/folders') do |f| expect(f.stdout).not_to match(%r{nomatch-folder}) end end end end diff --git a/spec/acceptance/grafana_plugin_spec.rb b/spec/acceptance/grafana_plugin_spec.rb index 2781b97..0a8eb6b 100644 --- a/spec/acceptance/grafana_plugin_spec.rb +++ b/spec/acceptance/grafana_plugin_spec.rb @@ -1,59 +1,62 @@ require 'spec_helper_acceptance' describe 'grafana_plugin' do context 'create plugin resource' do it 'runs successfully' do pp = <<-EOS class { 'grafana':} + include grafana::validator grafana_plugin { 'grafana-simple-json-datasource': } EOS apply_manifest(pp, catch_failures: true) apply_manifest(pp, catch_changes: true) end it 'has the plugin' do shell('grafana-cli plugins ls') do |r| expect(r.stdout).to match(%r{grafana-simple-json-datasource}) end end end context 'create plugin resource with repo' do it 'runs successfully' do pp = <<-EOS class { 'grafana':} + include grafana::validator grafana_plugin { 'grafana-simple-json-datasource': ensure => present, repo => 'https://nexus.company.com/grafana/plugins', } EOS apply_manifest(pp, catch_failures: true) apply_manifest(pp, catch_changes: true) end it 'has the plugin' do shell('grafana-cli plugins ls') do |r| expect(r.stdout).to match(%r{grafana-simple-json-datasource}) end end end context 'destroy plugin resource' do it 'runs successfully' do pp = <<-EOS class { 'grafana':} + include grafana::validator grafana_plugin { 'grafana-simple-json-datasource': ensure => absent, } EOS apply_manifest(pp, catch_failures: true) apply_manifest(pp, catch_changes: true) end it 'does not have the plugin' do shell('grafana-cli plugins ls') do |r| expect(r.stdout).not_to match(%r{grafana-simple-json-datasource}) end end end end