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..771c087 --- /dev/null +++ b/lib/puppet/provider/grafana_conn_validator/net_http.rb @@ -0,0 +1,68 @@ +# See: #10295 for more details. +# +# This is a workaround for bug: #4248 whereby ruby files outside of the normal +# provider/type path do not load until pluginsync has occured on the puppetmaster +# +# 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..958c67e --- /dev/null +++ b/lib/puppet/type/grafana_conn_validator.rb @@ -0,0 +1,37 @@ +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 + + 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 5b87440..953caaa 100644 --- a/lib/puppet/type/grafana_folder.rb +++ b/lib/puppet/type/grafana_folder.rb @@ -1,54 +1,58 @@ 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 datasource on' defaultto 1 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_plugin.rb b/lib/puppet/type/grafana_plugin.rb index 8832af8..9cd4497 100644 --- a/lib/puppet/type/grafana_plugin.rb +++ b/lib/puppet/type/grafana_plugin.rb @@ -1,47 +1,51 @@ Puppet::Type.newtype(:grafana_plugin) do desc <<-DESC manages grafana plugins @example Install a grafana plugin grafana_plugin { 'grafana-simple-json-datasource': } @example Install a grafana plugin from different repo grafana_plugin { 'grafana-simple-json-datasource': ensure => present, repo => 'https://nexus.company.com/grafana/plugins', } @example Uninstall a grafana plugin grafana_plugin { 'grafana-simple-json-datasource': ensure => absent, } @example Show resources $ puppet resource grafana_plugin DESC ensurable do defaultto(:present) newvalue(:present) do provider.create end newvalue(:absent) do provider.destroy end end newparam(:name, namevar: true) do desc 'The name of the plugin to enable' newvalues(%r{^\S+$}) end newparam(:repo) do desc 'The URL of an internal plugin server' validate do |value| unless value =~ %r{^https?://} raise ArgumentError, format('%s is not a valid URL', value) end end 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 61d8ac3..f65b6f6 100644 --- a/lib/puppet/type/grafana_user.rb +++ b/lib/puppet/type/grafana_user.rb @@ -1,70 +1,74 @@ 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' 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..edadc11 --- /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_api_path).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..9e054f4 --- /dev/null +++ b/manifests/validator.pp @@ -0,0 +1,25 @@ +# == Class: grafana::validator +# +# Includes `` resource +# +# === Parameters +# [*grafana_url*] +# Grafana URL. +# +# [*grafana_api_path*] +# API path to validate with. +# +# === Examples +# +# include grafana::validator +# +class grafana::validator ( + String[1] $grafana_url = 'http://localhost:3000', + String[1] $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 2ef3ee7..a62f388 100644 --- a/spec/acceptance/grafana_folder_spec.rb +++ b/spec/acceptance/grafana_folder_spec.rb @@ -1,104 +1,107 @@ 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 folder' do pp = <<-EOS + include grafana::validator grafana_folder { 'example-folder': ensure => present, 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 the folder' do shell('curl --user admin:admin http://localhost:3000/api/folders') do |f| expect(f.stdout).to match(%r{example-folder}) 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 { '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 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