Project

General

Profile

« Previous | Next » 

Revision 51e26e56

Added by Dominic Cleal about 8 years ago

fixes #14455 - add rest_v3 smart proxy provider using OAuth gem

The new provider has fewer dependencies than rest_v2 (with
apipie-bindings), therefore it's easier to package the dependencies for
an AIO version of Puppet. puppet-agent-oauth will be provided by the
Foreman repositories to install the oauth gem into the AIO environment.

All Ruby 1.9+ installations, including Puppet AIO packages, won't need
a JSON package as they include a bundled 'json' library.

The provider's been split into a base type+provider (supported as per
PUP-2458) to make it easier to add and update other types in future.

The addition of the SSL CA in the type is required with the OAuth gem's
HTTP request support, as it enables verification by default when it
detects CA bundles in common locations.

Thanks to @liamjbennett, whose work this is partly based on.

closes GH-432

View differences:

.sync.yml
options:
platforms:
- 'ruby_18'
- gem: oauth
Rakefile:
param_docs_pattern:
- manifests/cli.pp
Gemfile
gem 'json'
gem 'webmock'
gem 'addressable', '< 2.4', {"platforms"=>["ruby_18"]}
gem 'oauth'
# vim:ft=ruby
README.md
`foreman_smartproxy` can create and manage registered smart proxies in
Foreman's database. Providers:
* `rest_v2` provider uses API v2 with apipie-bindings and OAuth (default)
* `rest_v3` provider uses API v2 with Ruby HTTP library, OAuth and JSON (default)
* `rest_v2` provider uses API v2 with apipie-bindings and OAuth
* `rest` provider uses API v1 with the foreman_api gem and OAuth (deprecated)
# Contributing
lib/puppet/feature/json.rb
require 'puppet/util/feature'
Puppet.features.add(:json, :libs => %{json})
lib/puppet/feature/oauth.rb
require 'puppet/util/feature'
Puppet.features.add(:oauth, :libs => %{oauth})
lib/puppet/provider/foreman_resource/rest_v3.rb
# Base provider for other Puppet types managing Foreman resources
#
# This provider uses Net::HTTP from Ruby stdlib, JSON (stdlib on 1.9+ or the
# gem on 1.8) and the oauth gem for auth, so requiring minimal dependencies.
require 'uri'
Puppet::Type.type(:foreman_resource).provide(:rest_v3) do
# when previous providers are installed, use this one
def self.specificity
super + 2
end
def oauth_consumer_key
@oauth_consumer_key ||= begin
if resource[:consumer_key]
resource[:consumer_key]
else
begin
YAML.load_file('/etc/foreman/settings.yaml')[:oauth_consumer_key]
rescue
fail "Resource #{resource[:name]} cannot be managed: No OAuth Consumer Key available"
end
end
end
end
def oauth_consumer_secret
@oauth_consumer_secret ||= begin
if resource[:consumer_secret]
resource[:consumer_secret]
else
begin
YAML.load_file('/etc/foreman/settings.yaml')[:oauth_consumer_secret]
rescue
fail "Resource #{resource[:name]} cannot be managed: No OAuth consumer secret available"
end
end
end
end
def oauth_consumer
@consumer ||= OAuth::Consumer.new(oauth_consumer_key, oauth_consumer_secret, {
:site => resource[:base_url],
:request_token_path => '',
:authorize_path => '',
:access_token_path => '',
:timeout => resource[:timeout],
:ca_file => resource[:ssl_ca]
})
end
def generate_token
OAuth::AccessToken.new(oauth_consumer)
end
def request(method, path, params = {}, data = nil, headers = {})
base_url = resource[:base_url]
base_url += '/' unless base_url.end_with?('/')
uri = URI.join(base_url, path)
uri.query = params.map { |p,v| "#{URI.escape(p.to_s)}=#{URI.escape(v.to_s)}" }.join('&') unless params.empty?
headers = {
'Accept' => 'application/json',
'Content-Type' => 'application/json',
'foreman_user' => resource[:effective_user]
}.merge(headers)
attempts = 0
begin
debug("Making #{method} request to #{uri}")
response = oauth_consumer.request(method, uri.to_s, generate_token, {}, data, headers)
debug("Received response #{response.code} from request to #{uri}")
response
rescue Timeout::Error => te
attempts = attempts + 1
if attempts < 5
warning("Timeout calling API at #{uri}. Retrying ..")
retry
else
raise Puppet::Error.new("Timeout calling API at #{uri}", te)
end
rescue Exception => ex
raise Puppet::Error.new("Exception #{ex} in #{method} request to: #{uri}", ex)
end
end
def success?(response)
(200..299).include?(response.code.to_i)
end
def error_message(response)
JSON.parse(response.body)['error']['full_messages'].join(' ') rescue "unknown error (response #{response.code})"
end
end
lib/puppet/provider/foreman_smartproxy/rest_v3.rb
Puppet::Type.type(:foreman_smartproxy).provide(:rest_v3, :parent => Puppet::Type.type(:foreman_resource).provider(:rest_v3)) do
confine :feature => [:json, :oauth]
def proxy
@proxy ||= begin
r = request(:get, 'api/v2/smart_proxies', :search => %{name="#{resource[:name]}"})
raise Puppet::Error.new("Proxy #{resource[:name]} cannot be retrieved: #{error_message(r)}") unless success?(r)
JSON.load(r.body)['results'][0]
end
end
def id
proxy ? proxy['id'] : nil
end
def exists?
!id.nil?
end
def create
post_data = {:smart_proxy => {:name => resource[:name], :url => resource[:url]}}.to_json
r = request(:post, 'api/v2/smart_proxies', {}, post_data)
raise Puppet::Error.new("Proxy #{resource[:name]} cannot be registered: #{error_message(r)}") unless success?(r)
end
def destroy
r = request(:delete, "api/v2/smart_proxies/#{id}")
raise Puppet::Error.new("Proxy #{resource[:name]} cannot be removed: #{error_message(r)}") unless success?(r)
@proxy = nil
end
def url
proxy ? proxy['url'] : nil
end
def url=(value)
post_data = {:smart_proxy => {:url => value}}.to_json
r = request(:put, "api/v2/smart_proxies/#{id}", {}, post_data)
raise Puppet::Error.new("Proxy #{resource[:name]} cannot be updated: #{error_message(r)}") unless success?(r)
end
def refresh_features!
r = request(:put, "api/v2/smart_proxies/#{id}/refresh")
raise Puppet::Error.new("Proxy #{resource[:name]} cannot be refreshed: #{error_message(r)}") unless success?(r)
end
end
lib/puppet/type/foreman_resource.rb
Puppet::Type.newtype(:foreman_resource) do
desc 'Abstract type for Foreman resources.'
end
lib/puppet/type/foreman_smartproxy.rb
desc 'Foreman oauth consumer_secret'
end
newparam(:ssl_ca) do
desc 'Foreman SSL CA (certificate authority) for verification'
end
newproperty(:url) do
desc 'The url of the smartproxy'
isrequired
manifests/providers.pp
#
# === Parameters:
#
# $oauth:: Install oauth dependency
# type:boolean
#
# $oauth_package:: Name of oauth package
#
# $json:: Install json dependency, not required on Ruby 1.9 or higher
# type:boolean
#
# $json_package:: Name of json package
#
# $apipie_bindings:: Install apipie-bindings dependency
# type:boolean
#
......
# $foreman_api_package:: Name of foreman_api package
#
class foreman::providers(
$oauth = $::foreman::providers::params::oauth,
$oauth_package = $::foreman::providers::params::oauth_package,
$json = $::foreman::providers::params::json,
$json_package = $::foreman::providers::params::json_package,
$apipie_bindings = $::foreman::providers::params::apipie_bindings,
$apipie_bindings_package = $::foreman::providers::params::apipie_bindings_package,
$foreman_api = $::foreman::providers::params::foreman_api,
$foreman_api_package = $::foreman::providers::params::foreman_api_package,
) inherits foreman::providers::params {
validate_bool($apipie_bindings, $foreman_api)
validate_string($apipie_bindings_package, $foreman_api_package)
validate_bool($oauth, $json, $apipie_bindings, $foreman_api)
validate_string($oauth_package, $json_package, $apipie_bindings_package, $foreman_api_package)
if $oauth {
package { $oauth_package:
ensure => installed,
}
}
if $json {
package { $json_package:
ensure => installed,
}
}
if $apipie_bindings {
package { $apipie_bindings_package:
manifests/providers/params.pp
# foreman::providers default parameters
class foreman::providers::params {
# Dependency packages for different providers supplied in this module
$apipie_bindings = true
$oauth = true
$json = (versioncmp($::rubyversion, '1.9') < 0)
$apipie_bindings = false
$foreman_api = false
# OS specific package names
case $::osfamily {
'RedHat': {
if versioncmp($::puppetversion, '4.0') >= 0 {
$oauth_package = 'puppet-agent-oauth'
} else {
$oauth_package = 'rubygem-oauth'
}
$json_package = 'rubygem-json'
$apipie_bindings_package = 'rubygem-apipie-bindings'
$foreman_api_package = 'rubygem-foreman_api'
}
'Debian': {
if versioncmp($::puppetversion, '4.0') >= 0 {
$oauth_package = 'puppet-agent-oauth'
} else {
$oauth_package = 'ruby-oauth'
}
$json_package = 'ruby-json'
$apipie_bindings_package = 'ruby-apipie-bindings'
$foreman_api_package = 'ruby-foreman-api'
}
'FreeBSD': {
$oauth_package = 'rubygem-oauth'
$json_package = 'rubygem-json'
$apipie_bindings_package = 'rubygem-apipie-bindings'
$foreman_api_package = 'rubygem-foreman_api'
}
'Linux': {
case $::operatingsystem {
'Amazon': {
if versioncmp($::puppetversion, '4.0') >= 0 {
$oauth_package = 'puppet-agent-oauth'
} else {
$oauth_package = 'rubygem-oauth'
}
$json_package = 'rubygem-json'
$apipie_bindings_package = 'rubygem-apipie-bindings'
$foreman_api_package = 'rubygem-foreman_api'
}
spec/classes/foreman_providers_spec.rb
case facts[:osfamily]
when 'RedHat'
oauth_os = 'rubygem-oauth'
json = 'rubygem-json'
apipie_bindings = 'rubygem-apipie-bindings'
foreman_api = 'rubygem-foreman_api'
when 'Debian'
oauth_os = 'ruby-oauth'
json = 'ruby-json'
apipie_bindings = 'ruby-apipie-bindings'
foreman_api = 'ruby-foreman-api'
end
context 'with defaults' do
it { should contain_package(apipie_bindings).with_ensure('installed') }
if facts[:rubyversion].start_with?('1.8')
it { should contain_package(json).with_ensure('installed') }
else
it { should_not contain_package(json) }
end
it { should_not contain_package(apipie_bindings) }
it { should_not contain_package(foreman_api) }
end
context 'with defaults on Puppet 3' do
let(:facts) { facts.merge(:puppetversion => '3.8.6') }
it { should contain_package(oauth_os).with_ensure('installed') }
end
context 'with defaults on Puppet 4' do
let(:facts) { facts.merge(:puppetversion => '4.0.0') }
it { should contain_package('puppet-agent-oauth').with_ensure('installed') }
end
context 'with foreman_api only' do
let(:params) do {
'apipie_bindings' => false,
......
it { should_not contain_package(apipie_bindings) }
it { should contain_package(foreman_api).with_ensure('installed') }
end
context 'with apipie_bindings => true' do
let(:params) do {
'apipie_bindings' => true,
} end
it { should contain_package(apipie_bindings).with_ensure('installed') }
end
context 'with json => true' do
let(:params) do {
'json' => true,
} end
it { should contain_package(json).with_ensure('installed') }
end
context 'with oauth => true' do
let(:facts) { facts.merge(:puppetversion => '3.8.6') }
let(:params) do {
'oauth' => true,
} end
it { should contain_package(oauth_os).with_ensure('installed') }
end
end
end
end
spec/classes/foreman_spec.rb
let :pre_condition do
"class { 'foreman': }
class { 'foreman::providers':
apipie_bindings => true,
apipie_bindings_package => 'apipie-bindings',
}"
end
spec/unit/foreman_resource_rest_v3_spec.rb
require 'spec_helper'
require 'oauth'
provider_class = Puppet::Type.type(:foreman_resource).provider(:rest_v3)
describe provider_class do
let(:resource) do
mock('resource')
end
let(:provider) do
provider = provider_class.new
provider.resource = resource
provider
end
describe '#generate_token' do
it 'returns an OAuth::AccessToken' do
provider.expects(:oauth_consumer).returns(OAuth::Consumer.new('test', 'test'))
expect(provider.generate_token).to be_an(OAuth::AccessToken)
end
end
describe '#oauth_consumer' do
it 'returns an OAuth::Consumer' do
provider.expects(:oauth_consumer_key).returns('oauth_key')
provider.expects(:oauth_consumer_secret).returns('oauth_secret')
resource.expects(:[]).with(:base_url).returns('https://foreman.example.com')
resource.expects(:[]).with(:ssl_ca).returns('/etc/foreman/ssl/ca.pem')
resource.expects(:[]).with(:timeout).returns(500)
consumer = provider.oauth_consumer
expect(consumer).to be_an(OAuth::Consumer)
expect(consumer.site).to eq('https://foreman.example.com')
expect(consumer.options[:ca_file]).to eq('/etc/foreman/ssl/ca.pem')
expect(consumer.options[:timeout]).to eq(500)
end
end
describe '#oauth_consumer_key' do
it 'uses resource consumer_key' do
resource.expects(:[]).twice.with(:consumer_key).returns('oauth_key')
expect(provider.oauth_consumer_key).to eq('oauth_key')
end
it 'uses settings.yaml if resource has no consumer_key' do
resource.expects(:[]).with(:consumer_key).returns(nil)
YAML.expects(:load_file).with('/etc/foreman/settings.yaml').returns(:oauth_consumer_key => 'oauth_key')
expect(provider.oauth_consumer_key).to eq('oauth_key')
end
end
describe '#oauth_consumer_secret' do
it 'uses resource consumer_secret' do
resource.expects(:[]).twice.with(:consumer_secret).returns('oauth_secret')
expect(provider.oauth_consumer_secret).to eq('oauth_secret')
end
it 'uses settings.yaml if resource has no consumer_secret' do
resource.expects(:[]).with(:consumer_secret).returns(nil)
YAML.expects(:load_file).with('/etc/foreman/settings.yaml').returns(:oauth_consumer_secret => 'oauth_secret')
expect(provider.oauth_consumer_secret).to eq('oauth_secret')
end
end
describe '#request' do
before do
resource.expects(:[]).with(:base_url).returns(base_url)
resource.expects(:[]).with(:effective_user).returns(effective_user)
provider.expects(:oauth_consumer).at_least_once.returns(consumer)
end
let(:base_url) { 'https://foreman.example.com' }
let(:consumer) { mock('oauth_consumer') }
let(:effective_user) { 'admin' }
it 'makes request via consumer and returns response' do
response = mock(:code => '200')
consumer.expects(:request).with(:get, 'https://foreman.example.com/api/v2/example', is_a(OAuth::AccessToken), {}, nil, is_a(Hash)).returns(response)
expect(provider.request(:get, 'api/v2/example')).to eq(response)
end
it 'specifies foreman_user header' do
consumer.expects(:request).with(:get, anything, anything, anything, anything, has_entry('foreman_user', 'admin')).returns(mock(:code => '200'))
provider.request(:get, 'api/v2/example')
end
it 'passes parameters' do
consumer.expects(:request).with(:get, 'https://foreman.example.com/api/v2/example?test=value', anything, anything, anything, anything).returns(mock(:code => '200'))
provider.request(:get, 'api/v2/example', :test => 'value')
end
it 'passes data' do
consumer.expects(:request).with(:get, anything, anything, anything, 'test', anything).returns(mock(:code => '200'))
provider.request(:get, 'api/v2/example', {}, 'test')
end
it 'merges headers' do
consumer.expects(:request).with(:get, anything, anything, anything, anything, has_entries('test' => 'value', 'Accept' => 'application/json')).returns(mock(:code => '200'))
provider.request(:get, 'api/v2/example', {}, nil, {'test' => 'value'})
end
describe 'with non-root base URL' do
let(:base_url) { 'https://foreman.example.com/foreman' }
it 'concatenates the base and request URLs' do
consumer.expects(:request).with(:get, 'https://foreman.example.com/foreman/api/v2/example', anything, anything, anything, anything).returns(mock(:code => '200'))
provider.request(:get, 'api/v2/example')
end
end
it 'retries on timeout' do
consumer.expects(:request).twice.with(any_parameters).raises(Timeout::Error).then.returns(mock(:code => '200'))
provider.request(:get, 'api/v2/example')
end
it 'fails resource after multiple timeouts' do
consumer.expects(:request).times(5).with(any_parameters).
raises(Timeout::Error).then.
raises(Timeout::Error).then.
raises(Timeout::Error).then.
raises(Timeout::Error).then.
raises(Timeout::Error)
expect { provider.request(:get, 'api/v2/example') }.to raise_error(Puppet::Error, /Timeout/)
end
it 'fails resource with network errors' do
consumer.expects(:request).raises(Errno::ECONNRESET)
expect { provider.request(:get, 'api/v2/example') }.to raise_error(Puppet::Error, /Exception/)
end
end
describe '#success?(response)' do
it 'returns true for response code in 2xx' do
expect(provider.success?(mock(:code => '256'))).to eq(true)
end
it 'returns false for non-2xx response code' do
expect(provider.success?(mock(:code => '404'))).to eq(false)
end
end
describe '#error_message(response)' do
it 'returns array of errors from JSON' do
expect(provider.error_message(mock(:body => '{"error":{"full_messages":["error1","error2"]}}'))).to eq('error1 error2')
end
it 'returns message for missing error messages' do
expect(provider.error_message(mock(:body => '{}', :code => 404))).to eq('unknown error (response 404)')
end
end
end
spec/unit/foreman_smartproxy_rest_v3_spec.rb
require 'spec_helper'
provider_class = Puppet::Type.type(:foreman_smartproxy).provider(:rest_v3)
describe provider_class do
let(:resource) do
Puppet::Type.type(:foreman_smartproxy).new(
:name => 'proxy.example.com',
:url => 'https://proxy.example.com:8443',
:base_url => 'https://foreman.example.com',
:consumer_key => 'oauth_key',
:consumer_secret => 'oauth_secret',
:effective_user => 'admin'
)
end
let(:provider) do
provider = provider_class.new
provider.resource = resource
provider
end
describe '#create' do
it 'sends POST request' do
provider.expects(:request).with(:post, 'api/v2/smart_proxies', {}, is_a(String)).returns(mock(:code => '201'))
provider.create
end
end
describe '#destroy' do
it 'sends DELETE request' do
provider.expects(:id).returns(1)
provider.expects(:request).with(:delete, 'api/v2/smart_proxies/1').returns(mock(:code => '200'))
provider.destroy
end
end
describe '#exists?' do
it 'returns true when ID is present' do
provider.expects(:id).returns(1)
expect(provider.exists?).to be true
end
it 'returns nil when ID is absent' do
provider.expects(:id).returns(nil)
expect(provider.exists?).to be false
end
end
describe '#id' do
it 'returns ID from proxy hash' do
provider.expects(:proxy).twice.returns({'id' => 1, 'name' => 'proxy.example.com'})
expect(provider.id).to eq(1)
end
it 'returns nil when proxy is absent' do
provider.expects(:proxy).returns(nil)
expect(provider.id).to be_nil
end
end
describe '#proxy' do
it 'returns proxy hash from API results' do
provider.expects(:request).with(:get, 'api/v2/smart_proxies', :search => 'name="proxy.example.com"').returns(
mock('response', :body => {:results => [{:id => 1, :name => 'proxy.example.com'}]}.to_json, :code => '200')
)
expect(provider.proxy['id']).to eq(1)
expect(provider.proxy['name']).to eq('proxy.example.com')
end
end
describe '#refresh_features!' do
it 'sends PUT request to /refresh' do
provider.expects(:id).returns(1)
provider.expects(:request).with(:put, 'api/v2/smart_proxies/1/refresh').returns(mock(:code => '200'))
provider.refresh_features!
end
end
describe '#url' do
it 'returns ID from proxy hash' do
provider.expects(:proxy).twice.returns({'id' => 1, 'url' => 'https://proxy.example.com:8443'})
expect(provider.url).to eq('https://proxy.example.com:8443')
end
it 'returns nil when proxy is absent' do
provider.expects(:proxy).returns(nil)
expect(provider.url).to be_nil
end
end
describe '#url=' do
it 'sends PUT request' do
provider.expects(:id).returns(1)
provider.expects(:request).with(:put, 'api/v2/smart_proxies/1', {}, includes('"url":"https://new.example.com:8443"')).returns(mock(:code => '200'))
provider.url = 'https://new.example.com:8443'
end
end
end

Also available in: Unified diff