diff --git a/lib/puppet/provider/grafana_team/grafana.rb b/lib/puppet/provider/grafana_team/grafana.rb new file mode 100644 index 0000000..07abd32 --- /dev/null +++ b/lib/puppet/provider/grafana_team/grafana.rb @@ -0,0 +1,238 @@ +# frozen_string_literal: true + +require 'json' + +require File.expand_path(File.join(File.dirname(__FILE__), '..', 'grafana')) + +Puppet::Type.type(:grafana_team).provide(:grafana, parent: Puppet::Provider::Grafana) do + desc 'Support for Grafana permissions' + + defaultfor kernel: 'Linux' + + def raise_on_error(code, message) + raise message if code != '200' + end + + def parse_response(data) + JSON.parse(data) + rescue JSON::ParserError + raise format('Fail to parse response: %s', response.body) + end + + def map_organizations(ids) + ids.map do |id| + response = send_request 'GET', format('%s/orgs/%s', resource[:grafana_api_path], id) + raise_on_error(response.code, format('Failed to retrieve organization %d (HTTP response: %s/%s)', id, response.code, response.body)) + + organization = parse_response(response.body) + { + id: organization['id'], + name: organization['name'] + } + end + end + + def organizations + response = send_request('GET', format('%s/orgs', resource[:grafana_api_path])) + raise_on_error(response.code, format('Fail to retrieve organizations (HTTP response: %s/%s)', response.code, response.body)) + organizations = JSON.parse(response.body) + map_organizations(organizations.map { |x| x['id'] }) + end + + def organization + return @organization if @organization + + org = resource[:organization] || resource[:target_name] + key = org.is_a?(Numeric) || org.match(%r{/^[0-9]*$/}) ? :id : :name + @organization = organizations.find { |x| x[key] == org } + # return @organization if @organization + + # raise format('Unknown organization: %s', org) + end + + def map_teams(teams) + teams['teams'].map do |team| + { + id: team['id'], + name: team['name'], + organization: team['orgId'], + membercount: team['membercount'], + permission: team['permission'], + email: team['email'] + } + end + end + + def teams + return [] unless organization + + set_current_organization + response = send_request('GET', format('%s/teams/search', resource[:grafana_api_path])) + raise_on_error(response.code, format('Fail to retrieve teams (HTTP response: %s/%s)', response.code, response.body)) + teams = parse_response(response.body) + map_teams(teams) + end + + def team + @team ||= teams.find { |x| x[:name] == resource[:name] } + end + + def map_preferences(preferences) + { + theme: preferences['theme'], + home_dashboard: preferences['homeDashboardId'], + timezone: preferences['timezone'] + } + end + + def preferences + team unless @team + return if @preferences + + response = send_request('GET', format('%s/teams/%s/preferences', resource[:grafana_api_path], @team[:id])) + raise_on_error(response.code, format('Fail to retrieve teams (HTTP response: %s/%s)', response.code, response.body)) + preferences = parse_response(response.body) + @preferences = map_preferences(preferences) + end + + def setup_save_preferences_data + endpoint = format('%s/teams/%s/preferences', resource[:grafana_api_path], @team[:id]) + dash = get_dashboard(resource[:home_dashboard]) + request_data = { + theme: resource[:theme], + homeDashboardId: dash[:id], + timezone: resource[:timezone] + } + ['PUT', endpoint, request_data] + end + + def save_preferences + team unless @team + set_current_organization + setup_save_preferences_data + response = send_request(*setup_save_preferences_data) + # TODO: Raise on error? + return if response.code == '200' || response.code == '412' + + raise format('Failed to update team %s, (HTTP response: %s/%s)', resource, response.code, response.body) + end + + def set_current_organization + response = send_request 'POST', format('%s/user/using/%s', resource[:grafana_api_path], organization[:id]) + return if response.code == '200' + + raise format('Failed to switch to org %s (HTTP response: %s/%s)', organization[:id], response.code, response.body) + end + + def home_dashboard + preferences unless @preferences + dash = get_dashboard(@preferences[:home_dashboard]) + return dash[:name] if dash + + nil + end + + def home_dashboard=(value) + + + resource[:home_dashboard] = value + save_preferences + end + + def setup_search_path(ident) + if ident.is_a?(Numeric) || ident.match(%r{/^[0-9]*$/}) + { + dashboardIds: ident, + type: 'dash-db' + } + else + { + query: ident, + type: 'dash-db' + } + end + end + + def get_dashboard(ident) + set_current_organization + return { id: 0, name: 'Default' } if ident == 0 # rubocop:disable Style/NumericPredicate + + search_path = setup_search_path(ident) + response = send_request('GET', format('%s/search', resource[:grafana_api_path]), nil, search_path) + raise_on_error(response.code, format('Fail to retrieve dashboars (HTTP response: %s/%s)', response.code, response.body)) + + dashboard = parse_response(response.body) + format_dashboard(dashboard) + end + + def format_dashboard(dashboard) + return { id: 0, name: 'Default' } unless dashboard.first + + { + id: dashboard.first['id'], + name: dashboard.first['title'] + } + end + + def theme + preferences unless @preferences + return @preferences[:theme] if @preferences + + nil + end + + def theme=(value) + resource[:theme] = value + save_preferences + end + + def timezone + preferences unless @preferences + return @preferences[:timezone] if @preferences + + nil + end + + def timezone=(value) + resource[:timezone] = value + save_preferences + end + + def setup_save_team_data + verb = 'POST' + endpoint = format('%s/teams', resource[:grafana_api_path]) + request_data = { name: resource[:name], email: resource[:email] } + if exists? + verb = 'PUT' + endpoint = format('%s/teams/%s', resource[:grafana_api_path], @team[:id]) + end + [verb, endpoint, request_data] + end + + def save_team + set_current_organization + response = send_request(*setup_save_team_data) + raise_on_error(response.code, format('Failed to update team %s, (HTTP response: %s/%s)', resource, response.code, response.body)) + end + + def create + save_team + save_preferences + end + + def destroy + return unless team + + response = send_request('DELETE', format('%s/teams/%s', resource[:grafana_api_path], @team[:id])) + return unless response.code != '200' + + raise Puppet::Error, format('Failed to delete team %s (HTTP response: %s/%s)', resource, response.code, response.body) + end + + def exists? + team + return true if @team && @team[:name] == resource[:name] + + false + end +end diff --git a/lib/puppet/type/grafana_team.rb b/lib/puppet/type/grafana_team.rb new file mode 100644 index 0000000..e99c915 --- /dev/null +++ b/lib/puppet/type/grafana_team.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +Puppet::Type.newtype(:grafana_team) do + @doc = 'Manage teams in Grafana' + + ensurable + + newparam(:name, namevar: true) do + desc 'The name of the team' + 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(:organization) do + desc 'The organization the team belongs to' + end + + newparam(:email) do + desc 'The email for the team' + defaultto '' + end + + newproperty(:home_dashboard) do + desc 'The id or name of the home dashboard' + end + + newproperty(:theme) do + desc 'The theme to use for the team' + end + + newproperty(:timezone) do + desc 'The timezone to use for the team' + end + + autorequire(:service) do + 'grafana-server' + end + + autorequire(:grafana_dashboard) do + catalog.resources.select { |r| r.is_a?(Puppet::Type.type(:grafana_dashboard)) } + end + + autorequire(:grafana_organization) do + catalog.resources.select { |r| r.is_a?(Puppet::Type.type(:grafana_organization)) } + end +end diff --git a/spec/unit/puppet/type/grafana_team_type_spec.rb b/spec/unit/puppet/type/grafana_team_type_spec.rb new file mode 100644 index 0000000..0f01c1b --- /dev/null +++ b/spec/unit/puppet/type/grafana_team_type_spec.rb @@ -0,0 +1,51 @@ +require 'spec_helper' + +describe Puppet::Type.type(:grafana_team) do + let(:gteam) do + described_class.new( + name: 'foo', + grafana_url: 'http://example.com', + grafana_user: 'admin', + grafana_password: 'admin', + home_dashboard: 'foo_dashboard', + organization: 'foo_organization' + ) + end + + context 'when setting parameters' do + it "fails if grafana_url isn't HTTP-based" do + expect do + described_class.new name: 'foo', grafana_url: 'example.com', content: '{}', ensure: :present + end.to raise_error(Puppet::Error, %r{not a valid URL}) + end + + # rubocop:disable RSpec/MultipleExpectations + it 'accepts valid parameters' do + expect(gteam[:name]).to eq('foo') + expect(gteam[:grafana_user]).to eq('admin') + expect(gteam[:grafana_password]).to eq('admin') + expect(gteam[:grafana_url]).to eq('http://example.com') + expect(gteam[:home_dashboard]).to eq('foo_dashboard') + expect(gteam[:organization]).to eq('foo_organization') + end + # rubocop:enable RSpec/MultipleExpectations + + it 'autorequires the grafana-server for proper ordering' do + catalog = Puppet::Resource::Catalog.new + service = Puppet::Type.type(:service).new(name: 'grafana-server') + catalog.add_resource service + catalog.add_resource gteam + + relationship = gteam.autorequire.find do |rel| + (rel.source.to_s == 'Service[grafana-server]') && (rel.target.to_s == gteam.to_s) + end + expect(relationship).to be_a Puppet::Relationship + end + + it 'does not autorequire the service it is not managed' do + catalog = Puppet::Resource::Catalog.new + catalog.add_resource gteam + expect(gteam.autorequire).to be_empty + end + end +end