diff --git a/lib/puppet/provider/grafana_membership/grafana.rb b/lib/puppet/provider/grafana_membership/grafana.rb new file mode 100644 index 0000000..a64886d --- /dev/null +++ b/lib/puppet/provider/grafana_membership/grafana.rb @@ -0,0 +1,252 @@ +# frozen_string_literal: true + +require 'json' + +require File.expand_path(File.join(File.dirname(__FILE__), '..', 'grafana')) + +Puppet::Type.type(:grafana_membership).provide(:grafana, parent: Puppet::Provider::Grafana) do + desc 'Support for Grafana memberships' + + defaultfor kernel: 'Linux' + + 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[:membership_type] == :organization ? resource[:target_name] : resource[:organization] + 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 + # return @team if @team + + @team ||= teams.find { |x| x[:name] == resource[:target_name] } + # return @team if @team + + # raise format('Unknown team: %s', resource[:target_name]) + end + + def map_team_members(members) + members.map do |member| + { + id: member['userId'], + target_name: member['teamId'], + organization: member['orgId'] + } + end + end + + def team_members + response = send_request('GET', format('%s/teams/%s/members', resource[:grafana_api_path], @team[:id])) + raise_on_error(response.code, format('Fail to retrieve teams (HTTP response: %s/%s)', response.code, response.body)) + members = parse_response(response.body) + members ? map_team_members(members) : [] + end + + def team_member + @team_member ||= team_members.find { |x| x[:id] == @user[:id] } + end + + def raise_on_error(code, message) + raise message if code != '200' + end + + def grafana_api_path + resource[:grafana_api_path] + end + + def parse_response(data) + JSON.parse(data) + rescue JSON::ParserError + raise format('Fail to parse response: %s', response.body) + end + + def send_users_request + return '[]' unless organization + + set_current_organization + response = send_request('GET', format('%s/org/users', grafana_api_path)) + raise_on_error(response.code, format('Fail to retrieve users (HTTP response: %s/%s)', response.code, response.body)) + response.body + end + + def map_users(users) + users.map do |user| + { + id: user['userId'], + name: user['login'], + organization: user['orgId'], + role: user['role'] + } + end + end + + def users + users = parse_response(send_users_request) + map_users(users) + end + + def user + @user ||= users.find { |x| x[:name] == resource[:user_name] } + # return @user if @user + + # raise format('Unknown user: %s', resource[:user_name]) + 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 role + user unless @user + return @user[:role] if @user + + nil + end + + def role=(value) + resource[:role] = value + save_membership + end + + def save_membership + send(format('save_membership_%s', resource[:membership_type])) + end + + def check_org_team_and_user_exist + raise(format('Unknown organization: %s', resource[:organization])) unless organization + + set_current_organization + raise('Unknown team or user') unless team && user + end + + def save_membership_team + check_org_team_and_user_exist + endpoint = format('%s/teams/%s/members', resource[:grafana_api_path], @team[:id]) + response = send_request('POST', endpoint, userId: @user[:id]) + raise_on_error(response.code, format('Failed to update membership %s, (HTTP response: %s/%s)', resource, response.code, response.body)) + end + + def setup_save_mem_org_data + verb = 'POST' + endpoint = format('%s/org/users', resource[:grafana_api_path]) + request_data = { + role: resource[:role], + loginOrEmail: resource[:user_name] + } + if exists? + verb = 'PATCH' + endpoint = format('%s/org/users/%s', resource[:grafana_api_path], @user[:id]) + end + [verb, endpoint, request_data] + end + + def save_membership_organization + set_current_organization + response = send_request(*setup_save_mem_org_data) + raise_on_error(response.code, format('Failed to update membership %s, (HTTP response: %s/%s)', resource, response.code, response.body)) + end + + def create + save_membership + end + + def setup_destroy_data + if resource[:membership_type] == :organization + endpoint = format('%s/org/users/%s', resource[:grafana_api_path], @user[:id]) + else # team + team unless @team + endpoint = format('%s/teams/%s/members/%s', resource[:grafana_api_path], @team[:id], @user[:id]) + end + ['DELETE', endpoint] + end + + def destroy_team_membership + return unless user && organization && team + + endpoint = format('%s/teams/%s/members/%s', resource[:grafana_api_path], @team[:id], @user[:id]) + response = send_request('DELETE', endpoint) + raise_on_error(response.code, format('Failed to delete team membership (HTTP response: %s/%s)', response.code, response.body)) + end + + def destroy_organization_membership + return unless user && organization + + endpoint = format('%s/org/users/%s', resource[:grafana_api_path], @user[:id]) + response = send_request('DELETE', endpoint) + raise_on_error(response.code, format('Failed to delete organization membership (HTTP response: %s/%s)', response.code, response.body)) + end + + def destroy + set_current_organization + resource[:membership_type] == :organization ? destroy_organization_membership : destroy_team_membership + end + + def user_in_organization? + organization + return true if @user && @organization && @user[:organization] == @organization[:id] + + false + end + + def user_in_team? + team_member if team + return true if @user && @team && @team_member + + false + end + + def exists? + user + resource[:membership_type] == :organization ? user_in_organization? : user_in_team? + end +end diff --git a/lib/puppet/type/grafana_membership.rb b/lib/puppet/type/grafana_membership.rb new file mode 100644 index 0000000..a49eecc --- /dev/null +++ b/lib/puppet/type/grafana_membership.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +Puppet::Type.newtype(:grafana_membership) do + @doc = 'Manage resource memberships in Grafana' + + ensurable + + newparam(:name, namevar: true) do + desc 'The name of the membership.' + end + + newparam(:user_name) do + desc 'The name of the user to add membership for' + end + + newparam(:target_name) do + desc 'The name of the target to add membership for' + end + + newparam(:organization) do + desc 'The name of the organization to add membership for (team only)' + defaultto 'Main Org.' + end + + newparam(:grafana_api_path) do + desc 'The absolute path to the API endpoint' + defaultto '/api' + + validate do |value| + raise ArgumentError, format('%s is not a valid API path', value) unless value =~ %r{^/.*/?api$} + end + end + + newparam(:grafana_url) do + desc 'The URL of the Grafana server' + defaultto '' + + validate do |value| + raise ArgumentError, format('%s is not a valid URL', value) unless value =~ %r{^https?://} + 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(:membership_type) do + desc 'The underlying type of the membership (organization, team)' + newvalues(:organization, :team) + end + + newproperty(:role) do + desc 'The role to apply to the membership (Admin, Editor, Viewer)' + newvalues(:Admin, :Editor, :Viewer) + end + + autorequire(:service) do + 'grafana-server' + end + + autorequire(:grafana_organization) do + catalog.resources.select { |r| r.is_a?(Puppet::Type.type(:grafana_organization)) } + end + + autorequire(:grafana_team) do + catalog.resources.select { |r| r.is_a?(Puppet::Type.type(:grafana_team)) } + end + + autorequire(:grafana_membership) do + catalog.resources.select { |r| r.is_a?(Puppet::Type.type(:grafana_membership)) && r['membership_type'] == :organization } if self[:membership_type] == :team + end +end diff --git a/spec/grafana_membership_type_spec.rb b/spec/grafana_membership_type_spec.rb new file mode 100644 index 0000000..31ded64 --- /dev/null +++ b/spec/grafana_membership_type_spec.rb @@ -0,0 +1,64 @@ +require 'spec_helper' + +describe Puppet::Type.type(:grafana_membership) do + let(:gmembership) do + described_class.new( + title: 'foo_title', + user_name: 'foo_user', + target_name: 'foo_target', + grafana_url: 'http://example.com/', + grafana_api_path: '/api', + membership_type: 'organization', + role: 'Viewer', + ensure: :present + ) + end + + context 'when setting parameters' do + it "fails if grafana_url isn't HTTP-based" do + expect do + described_class.new title: 'foo_title', name: 'foo', grafana_url: 'example.com', ensure: :present + end.to raise_error(Puppet::Error, %r{not a valid URL}) + end + + it "fails if grafana_api_path isn't properly formed" do + expect do + described_class.new title: 'foo_title', grafana_url: 'http://example.com', grafana_api_path: '/invalidpath', ensure: :present + end.to raise_error(Puppet::Error, %r{not a valid API path}) + end + + it 'fails if membership type not valid' do + expect do + described_class.new title: 'foo title', membership_type: 'foo' + end.to raise_error(Puppet::Error, %r{Invalid value "foo"}) + end + + # rubocop:disable RSpec/MultipleExpectations + it 'accepts valid parameters' do + expect(gmembership[:user_name]).to eq('foo_user') + expect(gmembership[:target_name]).to eq('foo_target') + expect(gmembership[:grafana_api_path]).to eq('/api') + expect(gmembership[:grafana_url]).to eq('http://example.com/') + expect(gmembership[:membership_type]).to eq(: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 gmembership + + relationship = gmembership.autorequire.find do |rel| + (rel.source.to_s == 'Service[grafana-server]') && (rel.target.to_s == gmembership.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 gmembership + expect(gmembership.autorequire).to be_empty + end + end +end