diff --git a/README.md b/README.md index 519ed5130..fe59d0edf 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,10 @@ seen in _Administer > Settings_. The `cli` provider uses `foreman-rake` to chang `foreman_smartproxy` can create and manage registered smart proxies in Foreman's database. The `rest_v3` provider uses the API with Ruby's HTTP library, OAuth and JSON. +`foreman_hostgroup` can be used to create and destroy hostgroups. Nested hostgroups are supported +and hostgroups can be assigned to locations/organizations. +The type currently doesn't support other properties such as `environment`, `puppet classes` etc. + ## Foreman ENC via hiera There is a function `foreman::enc` to retrieve the ENC data. This returns the diff --git a/lib/puppet/provider/foreman_hostgroup/rest_v3.rb b/lib/puppet/provider/foreman_hostgroup/rest_v3.rb new file mode 100644 index 000000000..8b29458d9 --- /dev/null +++ b/lib/puppet/provider/foreman_hostgroup/rest_v3.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +Puppet::Type.type(:foreman_hostgroup).provide( + :rest_v3, + parent: Puppet::Type.type(:foreman_resource).provider(:rest_v3) +) do + confine feature: %i[json oauth] + + def initialize(value = {}) + super + @property_flush = {} + end + + def exists? + !id.nil? + end + + def create + Puppet.debug("Creating Foreman Hostgroup #{resource[:name]} with parent #{resource[:parent_hostgroup]}") + + if resource[:parent_hostgroup] && parent_hostgroup_id.nil? + raise Puppet::Error, + "Parent hostgroup #{resource[:parent_hostgroup]} for #{resource[:name]} not found" + end + + organization_ids = resource[:organizations]&.map { |org| organization_id(org) } + location_ids = resource[:locations]&.map { |loc| location_id(loc) } + + post_data = { + hostgroup: { + name: resource[:name], + parent_id: parent_hostgroup_id, + description: resource[:description], + organization_ids: organization_ids, + location_ids: location_ids + } + }.to_json + path = 'api/v2/hostgroups' + r = request(:post, path, {}, post_data) + + return if success?(r) + + raise Puppet::Error, "Error making POST request to Foreman at #{request_uri(path)}: #{error_message(r)}" + end + + def destroy + Puppet.debug("Destroying Foreman Hostgroup #{resource[:name]} with parent #{resource[:parent_hostgroup]}") + path = "api/v2/hostgroups/#{id}" + r = request(:delete, path) + + unless success?(r) + error_string = "Error making DELETE request to Foreman at #{request_uri(path)}: #{error_message(r)}" + raise Puppet::Error, error_string + end + + @hostgroup = nil + end + + def flush + return if @property_flush.empty? + + Puppet.debug "Calling API to update properties for #{resource[:name]}" + + path = "api/v2/hostgroups/#{id}" + r = request(:put, path, {}, { hostgroup: @property_flush }.to_json) + + return if success?(r) + + raise Puppet::Error, "Error making PUT request to Foreman at #{request_uri(path)}: #{error_message(r)}" + end + + # Property getters + def description + hostgroup ? hostgroup['description'] : nil + end + + def organizations + hostgroup ? hostgroup['organizations'].map { |org| org['title'] } : nil + end + + def locations + hostgroup ? hostgroup['locations'].map { |org| org['title'] } : nil + end + + # Property setters + # If one of more properties is being modified then group all of these updates in @property_flush so that we can update them in a single API call in `flush()` + def description=(value) + @property_flush[:description] = value + end + + def organizations=(value) + @property_flush[:organization_ids] = value.map { |org| organization_id(org) } + end + + def locations=(value) + @property_flush[:location_ids] = value.map { |loc| location_id(loc) } + end + + private + + def hostgroup + @hostgroup ||= begin + path = 'api/v2/hostgroups' + search_name = resource[:name] + search_title = if resource[:parent_hostgroup] + "#{resource[:parent_hostgroup]}/#{resource[:name]}" + else + search_name + end + Puppet.debug("Searching for hostgroup with name #{search_name} and title #{search_title}") + r = request(:get, path, search: %(title="#{search_title}" and name="#{search_name}")) + + raise Puppet::Error, "Error making GET request to Foreman at #{request_uri(path)}: #{error_message(r)}" unless success?(r) + + results = JSON.parse(r.body)['results'] + unless results.empty? + raise Puppet::Error, "Too many hostgroups found when looking for hostgroup with name #{search_name} and title #{search_title}" if results.size > 1 + + get_hostgroup_by_id(results[0]['id']) + end + end + end + + def get_hostgroup_by_id(id) + path = "api/v2/hostgroups/#{id}" + r = request(:get, path) + + raise Puppet::Error, "Error making GET request to Foreman at #{request_uri(path)}: #{error_message(r)}" unless success?(r) + + JSON.parse(r.body) + end + + def id + hostgroup ? hostgroup['id'] : nil + end + + def parent_hostgroup + return nil unless resource[:parent_hostgroup] + + @parent_hostgroup ||= begin + path = 'api/v2/hostgroups' + search_title = resource[:parent_hostgroup] + search_name = resource[:parent_hostgroup_name] || search_title.split('/').last + Puppet.debug("Searching for parent hostgroup with name #{search_name} and title #{search_title}") + r = request(:get, path, search: %(title="#{search_title}" and name="#{search_name}")) + + raise Puppet::Error, "Error making GET request to Foreman at #{request_uri(path)}: #{error_message(r)}" unless success?(r) + + results = JSON.parse(r.body)['results'] + raise Puppet::Error, "Parent hostgroup #{resource[:parent_hostgroup]} for #{resource[:name]} not found" if results.empty? + raise Puppet::Error, "Too many hostgroups found when looking for parent hostgroup with name #{search_name} and title #{search_title}" if results.size > 1 + + get_hostgroup_by_id(results[0]['id']) + end + end + + def parent_hostgroup_id + parent_hostgroup ? parent_hostgroup['id'] : nil + end + + def organization_id(organization_title) + title_to_id('organizations', organization_title) + end + + def location_id(location_title) + title_to_id('locations', location_title) + end + + # Returns the id of a location/organization based on its title + def title_to_id(type, title) + path = "api/v2/#{type}" + r = request(:get, path, search: %(title="#{title}")) + unless success?(r) + raise Puppet::Error, + "Error making GET request to Foreman at #{request_uri(path)}: #{error_message(r)}" + end + results = JSON.parse(r.body)['results'] + raise Puppet::Error, "#{title} not found in #{type}" unless results.size == 1 + + results[0]['id'] + end +end diff --git a/lib/puppet/type/foreman_host.rb b/lib/puppet/type/foreman_host.rb index 483703aa1..64dcc8f50 100644 --- a/lib/puppet/type/foreman_host.rb +++ b/lib/puppet/type/foreman_host.rb @@ -3,6 +3,7 @@ Puppet::Type.newtype(:foreman_host) do desc 'foreman_host creates a host in foreman.' + instance_eval(&PuppetX::Foreman::Common::REST_API_COMMON_PARAMS) instance_eval(&PuppetX::Foreman::Common::FOREMAN_HOST_PARAMS) newparam(:facts) do diff --git a/lib/puppet/type/foreman_hostgroup.rb b/lib/puppet/type/foreman_hostgroup.rb new file mode 100644 index 000000000..295be1a19 --- /dev/null +++ b/lib/puppet/type/foreman_hostgroup.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require_relative '../../puppet_x/foreman/common' +Puppet::Type.newtype(:foreman_hostgroup) do + desc 'foreman_hostgroup manages hostgroups in foreman.' + + instance_eval(&PuppetX::Foreman::Common::REST_API_COMMON_PARAMS) + + def self.title_patterns + [ + [ + %r{^(.+)/(.+)$}, + [ + [:parent_hostgroup], + [:name] + ] + ], + [ + %r{(.+)}, + [ + [:name] + ] + ] + ] + end + newparam(:name, namevar: true) do + desc 'The name of the hostgroup.' + end + + newparam(:parent_hostgroup, namevar: true) do + desc 'The full title of the parent hostgroup' + end + + newparam(:parent_hostgroup_name) do + desc 'The name of the parent hostgroup. This only needs to be given if your hostgroups contain slashes!' + end + + newproperty(:description) do + desc 'The hostgroup\'s `description`' + end + + newproperty(:organizations, array_matching: :all) do + desc 'An array of organizations (full titles) that this hostgroup should be part of' + def insync?(is) # rubocop:disable Naming/MethodParameterName + is.sort == should.sort + end + end + + newproperty(:locations, array_matching: :all) do + desc 'An array of locations (full titles) that this hostgroup should be part of' + def insync?(is) # rubocop:disable Naming/MethodParameterName + is.sort == should.sort + end + end + + autorequire(:foreman_hostgroup) do + self[:parent_hostgroup] if self[:ensure] == :present + end +end diff --git a/lib/puppet/type/foreman_instance_host.rb b/lib/puppet/type/foreman_instance_host.rb index d84199ab4..21133c5e8 100644 --- a/lib/puppet/type/foreman_instance_host.rb +++ b/lib/puppet/type/foreman_instance_host.rb @@ -3,6 +3,7 @@ Puppet::Type.newtype(:foreman_instance_host) do desc 'foreman_instance_host marks a host as belonging to the set of hosts that make up the Foreman instance/application' + instance_eval(&PuppetX::Foreman::Common::REST_API_COMMON_PARAMS) instance_eval(&PuppetX::Foreman::Common::FOREMAN_HOST_PARAMS) autorequire(:foreman_host) do diff --git a/lib/puppet/type/foreman_smartproxy.rb b/lib/puppet/type/foreman_smartproxy.rb index 2212e9b48..55aad2f90 100644 --- a/lib/puppet/type/foreman_smartproxy.rb +++ b/lib/puppet/type/foreman_smartproxy.rb @@ -1,30 +1,16 @@ +require_relative '../../puppet_x/foreman/common' + Puppet::Type.newtype(:foreman_smartproxy) do desc 'foreman_smartproxy registers a smartproxy in foreman.' feature :feature_validation, "Enabled features can be validated", methods: [:features, :features=] - ensurable + instance_eval(&PuppetX::Foreman::Common::REST_API_COMMON_PARAMS) newparam(:name, :namevar => true) do desc 'The name of the smartproxy.' end - newparam(:base_url) do - desc 'Foreman\'s base url.' - end - - newparam(:effective_user) do - desc 'Foreman\'s effective user for the registration (usually admin).' - end - - newparam(:consumer_key) do - desc 'Foreman oauth consumer_key' - end - - newparam(:consumer_secret) do - desc 'Foreman oauth consumer_secret' - end - newproperty(:features, required_features: :feature_validation, array_matching: :all) do desc 'Features expected to be enabled on the smart proxy. Setting this validates that all of the listed features are functional, according to @@ -42,36 +28,12 @@ def is_to_s(value) alias should_to_s is_to_s end - newparam(:ssl_ca) do - desc 'Foreman SSL CA (certificate authority) for verification' - end - newproperty(:url) do desc 'The url of the smartproxy' isrequired newvalues(URI.regexp) end - newparam(:timeout) do - desc "Timeout for HTTP(s) requests" - - munge do |value| - value = value.shift if value.is_a?(Array) - begin - value = Integer(value) - rescue ArgumentError - raise ArgumentError, "The timeout must be a number.", $!.backtrace - end - [value, 0].max - end - - defaultto 500 - end - - autorequire(:anchor) do - ['foreman::providers::oauth'] - end - def refresh if @parameters[:ensure].retrieve == :present provider.refresh_features! if provider.respond_to?(:refresh_features!) @@ -79,5 +41,4 @@ def refresh debug 'Skipping refresh; smart proxy is not registered' end end - end diff --git a/lib/puppet/type/foreman_smartproxy_host.rb b/lib/puppet/type/foreman_smartproxy_host.rb index 1e850b38c..d4cfb2bda 100644 --- a/lib/puppet/type/foreman_smartproxy_host.rb +++ b/lib/puppet/type/foreman_smartproxy_host.rb @@ -3,6 +3,7 @@ Puppet::Type.newtype(:foreman_smartproxy_host) do desc 'foreman_smartproxy_host marks a host as a smart proxy.' + instance_eval(&PuppetX::Foreman::Common::REST_API_COMMON_PARAMS) instance_eval(&PuppetX::Foreman::Common::FOREMAN_HOST_PARAMS) autorequire(:foreman_host) do diff --git a/lib/puppet_x/foreman/common.rb b/lib/puppet_x/foreman/common.rb index 7193f3c93..63a7f4ebd 100644 --- a/lib/puppet_x/foreman/common.rb +++ b/lib/puppet_x/foreman/common.rb @@ -1,16 +1,10 @@ module PuppetX module Foreman module Common - FOREMAN_HOST_PARAMS = Proc.new do - ensurable - - newparam(:name, :namevar => true) do - desc 'The name of the resource.' - end - newparam(:hostname) do - desc 'The name of the host.' - end + # Parameters common to several types that use the rest_v3 api provider + REST_API_COMMON_PARAMS = Proc.new do + ensurable newparam(:base_url) do desc 'Foreman\'s base url.' @@ -49,7 +43,17 @@ module Common end autorequire(:anchor) do - ['foreman::service'] + ['foreman::service','foreman::providers::oauth'] + end + end + + FOREMAN_HOST_PARAMS = Proc.new do + newparam(:name, :namevar => true) do + desc 'The name of the resource.' + end + + newparam(:hostname) do + desc 'The name of the host.' end end end diff --git a/spec/acceptance/foreman_hostgroups_spec.rb b/spec/acceptance/foreman_hostgroups_spec.rb new file mode 100644 index 000000000..973e0ce83 --- /dev/null +++ b/spec/acceptance/foreman_hostgroups_spec.rb @@ -0,0 +1,66 @@ +require 'spec_helper_acceptance' + +describe 'Scenario: install foreman and manage some hostgroups', order: :defined do + before(:context) { purge_foreman } + + it_behaves_like 'an idempotent resource' do + let(:manifest) do + <<-PUPPET + include foreman + + Foreman_hostgroup { + ensure => present, + base_url => $foreman::foreman_url, + consumer_key => $foreman::oauth_consumer_key, + consumer_secret => $foreman::oauth_consumer_secret, + ssl_ca => $foreman::server_ssl_ca, + timeout => 5, + } + + foreman_hostgroup { 'example_hostgroup': + description => 'An example parent hostgroup', + } + + foreman_hostgroup { 'example_hostgroup/child': + description => 'An example child hostgroup', + } + + include foreman::cli + PUPPET + end + end + + it_behaves_like 'the foreman application' + it_behaves_like 'hammer' + + describe command('hammer hostgroup list') do + its(:stdout) { is_expected.to include('example_hostgroup/child') } + end + + describe 'removing a hostgroup' do + it_behaves_like 'an idempotent resource' do + let(:manifest) do + <<-PUPPET + include foreman + + Foreman_hostgroup { + ensure => present, + base_url => $foreman::foreman_url, + consumer_key => $foreman::oauth_consumer_key, + consumer_secret => $foreman::oauth_consumer_secret, + ssl_ca => $foreman::server_ssl_ca, + timeout => 5, + } + + foreman_hostgroup { 'example_hostgroup/child': + ensure => absent, + } + PUPPET + end + end + + describe command('hammer hostgroup list') do + its(:stdout) { is_expected.not_to include('example_hostgroup/child') } + end + end +end diff --git a/spec/types/foreman_hostgroup_spec.rb b/spec/types/foreman_hostgroup_spec.rb new file mode 100644 index 000000000..72b0c67e3 --- /dev/null +++ b/spec/types/foreman_hostgroup_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +describe 'foreman_hostgroup' do + let :title do + 'example_hostgroup' + end + + it { is_expected.to be_valid_type } + it { is_expected.to be_valid_type.with_provider(:rest_v3) } + + it { + expect(subject).to be_valid_type.with_properties( + %i[ + ensure + description + locations + organizations + ] + ) + } + + it { + expect(subject).to be_valid_type.with_parameters( + %i[ + parent_hostgroup + parent_hostgroup_name + base_url + effective_user + consumer_key + consumer_secret + ssl_ca + timeout + ] + ) + } +end diff --git a/spec/unit/foreman_hostgroup_rest_v3_spec.rb b/spec/unit/foreman_hostgroup_rest_v3_spec.rb new file mode 100644 index 000000000..1aa7c03cd --- /dev/null +++ b/spec/unit/foreman_hostgroup_rest_v3_spec.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Puppet::Type.type(:foreman_hostgroup).provider(:rest_v3) do + let(:basic_params) do + { + name: 'example_hostgroup', + base_url: 'https://foreman.example.com', + consumer_key: 'oauth_key', + consumer_secret: 'oauth_secret', + effective_user: 'admin' + } + end + + let(:resource) do + Puppet::Type.type(:foreman_hostgroup).new( + basic_params + ) + end + + let(:provider) do + provider = described_class.new + provider.resource = resource + provider + end + + describe '#create' do + let(:expected_post_data) do + { + 'hostgroup' => { + 'name' => 'example_hostgroup', + 'parent_id' => nil, + 'description' => nil, + 'organization_ids' => nil, + 'location_ids' => nil, + } + }.to_json + end + + it 'sends POST request' do + allow(provider).to receive(:request).with(:post, 'api/v2/hostgroups', {}, expected_post_data).and_return( + instance_double(Net::HTTPOK, code: '201', body: {}.to_json) + ) + provider.create + end + + context 'with description set' do + let(:resource) do + Puppet::Type.type(:foreman_hostgroup).new( + basic_params.merge(description: 'example description') + ) + end + let(:expected_post_data) do + { + 'hostgroup' => { + 'name' => 'example_hostgroup', + 'parent_id' => nil, + 'description' => 'example description', + 'organization_ids' => nil, + 'location_ids' => nil, + } + }.to_json + end + + it 'sends POST request' do + allow(provider).to receive(:request).with(:post, 'api/v2/hostgroups', {}, expected_post_data).and_return( + instance_double(Net::HTTPOK, code: '201', body: {}.to_json) + ) + provider.create + end + end + + context 'with organizations set' do + let(:resource) do + Puppet::Type.type(:foreman_hostgroup).new( + basic_params.merge(organizations: %w[org1 org2]) + ) + end + let(:expected_post_data) do + { + 'hostgroup' => { + 'name' => 'example_hostgroup', + 'parent_id' => nil, + 'description' => nil, + 'organization_ids' => [101, 102], + 'location_ids' => nil, + } + }.to_json + end + + it do + allow(provider).to receive(:organization_id).with('org1').and_return(101) + allow(provider).to receive(:organization_id).with('org2').and_return(102) + allow(provider).to receive(:request).with(:post, 'api/v2/hostgroups', {}, expected_post_data).and_return( + instance_double(Net::HTTPOK, code: '201', body: {}.to_json) + ) + provider.create + end + end + + context 'with locations set' do + let(:resource) do + Puppet::Type.type(:foreman_hostgroup).new( + basic_params.merge(locations: %w[loc1 loc2]) + ) + end + let(:expected_post_data) do + { + 'hostgroup' => { + 'name' => 'example_hostgroup', + 'parent_id' => nil, + 'description' => nil, + 'organization_ids' => nil, + 'location_ids' => [201, 202], + } + }.to_json + end + + it do + allow(provider).to receive(:location_id).with('loc1').and_return(201) + allow(provider).to receive(:location_id).with('loc2').and_return(202) + allow(provider).to receive(:request).with(:post, 'api/v2/hostgroups', {}, expected_post_data).and_return( + instance_double(Net::HTTPOK, code: '201', body: {}.to_json) + ) + provider.create + end + end + end + + describe '#destroy' do + it 'sends DELETE request' do + allow(provider).to receive(:id).and_return(42) + allow(provider).to receive(:request).with(:delete, 'api/v2/hostgroups/42').and_return( + instance_double(Net::HTTPOK, code: '201', body: {}.to_json) + ) + provider.destroy + end + end +end