From 41608dc6c8e3248049c91c2a3c8647f0cbd7caad Mon Sep 17 00:00:00 2001 From: Martin Magr Date: Thu, 5 Jun 2014 14:24:47 +0200 Subject: [PATCH] Implement Keystone domain creation Keystone domain has to be created for Heat. This patch implements this via helper script [1] since we don't have support for Keystone v3 API in puppet-keystone yet. This implementation should be refactored as soon as we will have v3 API available in puppet-keystone. For more info please check [2]. [1] https://github.com/openstack/heat/blob/master/bin/heat-keystone-setup-domain [2] https://bugzilla.redhat.com/show_bug.cgi?id=1076172 Change-Id: I036a84eee6b9d0afa9a9ed96849494324ba4c4db --- Gemfile | 3 + .../provider/heat_domain_id_setter/ruby.rb | 183 ++++++++++++++++++ lib/puppet/type/heat_config.rb | 4 + lib/puppet/type/heat_domain_id_setter.rb | 31 +++ manifests/keystone/domain.pp | 73 +++++++ spec/classes/heat_keystone_domain_spec.rb | 66 +++++++ spec/spec_helper.rb | 2 + .../heat_domain_id_setter/heat_spec.rb | 177 +++++++++++++++++ 8 files changed, 539 insertions(+) create mode 100644 lib/puppet/provider/heat_domain_id_setter/ruby.rb create mode 100644 lib/puppet/type/heat_domain_id_setter.rb create mode 100644 manifests/keystone/domain.pp create mode 100644 spec/classes/heat_keystone_domain_spec.rb create mode 100644 spec/unit/provider/heat_domain_id_setter/heat_spec.rb diff --git a/Gemfile b/Gemfile index 0d35201b..d965fa90 100644 --- a/Gemfile +++ b/Gemfile @@ -4,6 +4,9 @@ group :development, :test do gem 'puppetlabs_spec_helper', :require => false gem 'puppet-lint', '~> 0.3.2' gem 'rake', '10.1.1' + gem 'rspec', '< 2.99' + gem 'json' + gem 'webmock' end if puppetversion = ENV['PUPPET_GEM_VERSION'] diff --git a/lib/puppet/provider/heat_domain_id_setter/ruby.rb b/lib/puppet/provider/heat_domain_id_setter/ruby.rb new file mode 100644 index 00000000..ed1d34db --- /dev/null +++ b/lib/puppet/provider/heat_domain_id_setter/ruby.rb @@ -0,0 +1,183 @@ +## NB: This must work with Ruby 1.8! + +# This provider permits the stack_user_domain parameter in heat.conf +# to be set by providing a domain_name to the Puppet module and +# using the Keystone REST API to translate the name into the corresponding +# UUID. +# +# This requires that tenant names be unique. If there are multiple matches +# for a given tenant name, this provider will raise an exception. + +require 'rubygems' +require 'net/http' +require 'json' + +class KeystoneError < Puppet::Error +end + +class KeystoneConnectionError < KeystoneError +end + +class KeystoneAPIError < KeystoneError +end + +# Provides common request handling semantics to the other methods in +# this module. +# +# +req+:: +# An HTTPRequest object +# +url+:: +# A parsed URL (returned from URI.parse) +def handle_request(req, url) + begin + res = Net::HTTP.start(url.host, url.port) {|http| + http.request(req) + } + + if res.code != '200' + raise KeystoneAPIError, "Received error response from Keystone server at #{url}: #{res.message}" + end + rescue Errno::ECONNREFUSED => detail + raise KeystoneConnectionError, "Failed to connect to Keystone server at #{url}: #{detail}" + rescue SocketError => detail + raise KeystoneConnectionError, "Failed to connect to Keystone server at #{url}: #{detail}" + end + + res +end + +# Authenticates to a Keystone server and obtains an authentication token. +# It returns a 2-element +[token, authinfo]+, where +token+ is a token +# suitable for passing to openstack apis in the +X-Auth-Token+ header, and +# +authinfo+ is the complete response from Keystone, including the service +# catalog (if available). +# +# +auth_url+:: +# Keystone endpoint URL. This function assumes API version +# 2.0 and an administrative endpoint, so this will typically look like +# +http://somehost:35357/v2.0+. +# +# +username+:: +# Username for authentication. +# +# +password+:: +# Password for authentication +# +# +tenantID+:: +# Tenant UUID +# +# +tenantName+:: +# Tenant name +# +def keystone_v2_authenticate(auth_url, + username, + password, + tenantId=nil, + tenantName=nil) + + post_args = { + 'auth' => { + 'passwordCredentials' => { + 'username' => username, + 'password' => password + }, + }} + + if tenantId + post_args['auth']['tenantId'] = tenantId + end + + if tenantName + post_args['auth']['tenantName'] = tenantName + end + + url = URI.parse("#{auth_url}/tokens") + req = Net::HTTP::Post.new url.path + req['content-type'] = 'application/json' + req.body = post_args.to_json + + res = handle_request(req, url) + data = JSON.parse res.body + return data['access']['token']['id'], data +end + +# Queries a Keystone server to a list of all tenants. +# +# +auth_url+:: +# Keystone endpoint. See the notes for +auth_url+ in +# +keystone_v2_authenticate+. +# +# +token+:: +# A Keystone token that will be passed in requests as the value of the +# +X-Auth-Token+ header. +# +def keystone_v3_domains(auth_url, + token) + + auth_url.sub!('v2.0', 'v3') + url = URI.parse("#{auth_url}/domains") + req = Net::HTTP::Get.new url.path + req['content-type'] = 'application/json' + req['x-auth-token'] = token + + res = handle_request(req, url) + data = JSON.parse res.body + data['domains'] +end + +Puppet::Type.type(:heat_domain_id_setter).provide(:ruby) do + def authenticate + token, authinfo = keystone_v2_authenticate( + @resource[:auth_url], + @resource[:auth_username], + @resource[:auth_password], + nil, + @resource[:auth_tenant_name]) + + return token + end + + def find_domain_by_name(token) + domains = keystone_v3_domains( + @resource[:auth_url], + token) + domains.select{|domain| domain['name'] == @resource[:domain_name]} + end + + def exists? + false + end + + def create + config + end + + # This looks for the domain specified by the 'domain_name' parameter to + # the resource and returns the corresponding UUID if there is a single + # match. + # + # Raises a KeystoneAPIError if: + # + # - There are multiple matches, or + # - There are zero matches + def get_domain_id + token = authenticate + domains = find_domain_by_name(token) + + if domains.length == 1 + return domains[0]['id'] + elsif domains.length > 1 + name = domains[0]['name'] + raise KeystoneAPIError, 'Found multiple matches for domain name "#{name}"' + else + raise KeystoneAPIError, 'Unable to find matching domain' + end + end + + def config + Puppet::Type.type(:heat_config).new( + {:name => 'DEFAULT/stack_user_domain', :value => "#{get_domain_id}"} + ).create + end + +end diff --git a/lib/puppet/type/heat_config.rb b/lib/puppet/type/heat_config.rb index adcc5ce9..75613a93 100644 --- a/lib/puppet/type/heat_config.rb +++ b/lib/puppet/type/heat_config.rb @@ -40,4 +40,8 @@ Puppet::Type.newtype(:heat_config) do defaultto false end + def create + provider.create + end + end diff --git a/lib/puppet/type/heat_domain_id_setter.rb b/lib/puppet/type/heat_domain_id_setter.rb new file mode 100644 index 00000000..d6e1eeef --- /dev/null +++ b/lib/puppet/type/heat_domain_id_setter.rb @@ -0,0 +1,31 @@ +Puppet::Type.newtype(:heat_domain_id_setter) do + + ensurable + + newparam(:name, :namevar => true) do + desc 'The name of the setting to update' + end + + newparam(:domain_name) do + desc 'The heat domain name' + end + + newparam(:auth_url) do + desc 'The Keystone endpoint URL' + defaultto 'http://localhost:35357/v2.0' + end + + newparam(:auth_username) do + desc 'Username with which to authenticate' + defaultto 'admin' + end + + newparam(:auth_password) do + desc 'Password with which to authenticate' + end + + newparam(:auth_tenant_name) do + desc 'Tenant name with which to authenticate' + defaultto 'admin' + end +end diff --git a/manifests/keystone/domain.pp b/manifests/keystone/domain.pp new file mode 100644 index 00000000..5cb98b7e --- /dev/null +++ b/manifests/keystone/domain.pp @@ -0,0 +1,73 @@ +# == Class: heat::keystone::domain +# +# Configures heat domain in Keystone. +# +# Note: Implementation is done by heat-keystone-setup-domain script temporarily +# because currently puppet-keystone does not support v3 API +# +# === Parameters +# +# [*auth_url*] +# Keystone auth url +# +# [*keystone_admin*] +# Keystone admin user +# +# [*keystone_password*] +# Keystone admin password +# +# [*keystone_tenant*] +# Keystone admin tenant name +# +# [*domain_name*] +# Heat domain name. Defaults to 'heat'. +# +# [*domain_admin*] +# Keystone domain admin user which will be created. Defaults to 'heat_admin'. +# +# [*domain_password*] +# Keystone domain admin user password. Defaults to 'changeme'. +# +class heat::keystone::domain ( + $auth_url = undef, + $keystone_admin = undef, + $keystone_password = undef, + $keystone_tenant = undef, + $domain_name = 'heat', + $domain_admin = 'heat_admin', + $domain_password = 'changeme', +) { + + include heat::params + + $cmd_evn = [ + "OS_USERNAME=${keystone_admin}", + "OS_PASSWORD=${keystone_password}", + "OS_AUTH_URL=${auth_url}", + "HEAT_DOMAIN=${domain_name}", + "HEAT_DOMAIN_ADMIN=${domain_admin}", + "HEAT_DOMAIN_PASSWORD=${domain_password}" + ] + exec { 'heat_domain_create': + path => '/usr/bin', + command => 'heat-keystone-setup-domain &>/dev/null', + environment => $cmd_evn, + require => Package['heat-common'], + } + + heat_domain_id_setter { 'heat_domain_id': + ensure => present, + domain_name => $domain_name, + auth_url => $auth_url, + auth_username => $keystone_admin, + auth_password => $keystone_password, + auth_tenant_name => $keystone_tenant, + require => Exec['heat_domain_create'], + } + + heat_config { + 'DEFAULT/stack_domain_admin': value => $domain_admin; + 'DEFAULT/stack_domain_admin_password': value => $domain_password; + } + +} diff --git a/spec/classes/heat_keystone_domain_spec.rb b/spec/classes/heat_keystone_domain_spec.rb new file mode 100644 index 00000000..1755f45b --- /dev/null +++ b/spec/classes/heat_keystone_domain_spec.rb @@ -0,0 +1,66 @@ +require 'spec_helper' + +describe 'heat::keystone::domain' do + + let :params do { + :auth_url => 'http://127.0.0.1:35357/v2.0', + :keystone_admin => 'admin', + :keystone_password => 'admin_passwd', + :keystone_tenant => 'admin', + :domain_name => 'heat', + :domain_admin => 'heat_admin', + :domain_password => 'domain_passwd' + } + end + + shared_examples_for 'heat keystone domain' do + it 'configure heat.conf' do + should contain_heat_config('DEFAULT/stack_domain_admin').with_value(params[:domain_admin]) + should contain_heat_config('DEFAULT/stack_domain_admin_password').with_value(params[:domain_password]) + end + + it 'should configure heat domain id' do + should contain_heat_domain_id_setter('heat_domain_id').with( + :ensure => 'present', + :domain_name => params[:domain_name], + :auth_url => params[:auth_url], + :auth_username => params[:keystone_admin], + :auth_password => params[:keystone_password], + :auth_tenant_name => params[:keystone_tenant] + ) + end + + it 'should exec helper script' do + should contain_exec('heat_domain_create').with( + :command => 'heat-keystone-setup-domain &>/dev/null', + :path => '/usr/bin', + :require => 'Package[heat-common]', + :environment => [ + "OS_USERNAME=#{params[:keystone_admin]}", + "OS_PASSWORD=#{params[:keystone_password]}", + "OS_AUTH_URL=#{params[:auth_url]}", + "HEAT_DOMAIN=#{params[:domain_name]}", + "HEAT_DOMAIN_ADMIN=#{params[:domain_admin]}", + "HEAT_DOMAIN_PASSWORD=#{params[:domain_password]}" + ] + ) + end + end + + + context 'on Debian platforms' do + let :facts do + { :osfamily => 'Debian' } + end + + it_configures 'heat keystone domain' + end + + context 'on RedHat platforms' do + let :facts do + { :osfamily => 'RedHat' } + end + + it_configures 'heat keystone domain' + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 53d4dd02..27f3351d 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,5 +1,7 @@ require 'puppetlabs_spec_helper/module_spec_helper' require 'shared_examples' +require 'webmock/rspec' +require 'json' RSpec.configure do |c| c.alias_it_should_behave_like_to :it_configures, 'configures' diff --git a/spec/unit/provider/heat_domain_id_setter/heat_spec.rb b/spec/unit/provider/heat_domain_id_setter/heat_spec.rb new file mode 100644 index 00000000..3d748194 --- /dev/null +++ b/spec/unit/provider/heat_domain_id_setter/heat_spec.rb @@ -0,0 +1,177 @@ +require 'spec_helper' +require 'puppet' +require 'puppet/type/heat_domain_id_setter' + +provider_class = Puppet::Type.type(:heat_domain_id_setter).provider(:ruby) + +# used to simulate an authentication response from Keystone +# (POST v2.0/tokens) +auth_response = { + 'access' => { + 'token' => { + 'id' => 'TOKEN', + } + } +} + +# used to simulate a response to GET v3/domains +domains_response = { + 'domains' => [ + { + 'name' => 'heat', + 'id' => 'UUID_HEAT' + }, + { + 'name' => 'multiple_matches_domain', + 'id' => 'UUID1' + }, + { + 'name' => 'multiple_matches_domain', + 'id' => 'UUID2' + }, + ] +} + +# Stub for ini_setting resource +Puppet::Type.newtype(:ini_setting) do +end + +# Stub for ini_setting provider +Puppet::Type.newtype(:ini_setting).provide(:ruby) do + def create + end +end + +describe 'Puppet::Type.type(:heat_keystone_domain_id_setter)' do + let :params do + { + :name => 'heat_domain_id', + :ensure => 'present', + :domain_name => 'heat', + :auth_url => 'http://127.0.0.1:35357/v2.0', + :auth_username => 'admin', + :auth_password => 'admin_passwd', + :auth_tenant_name => 'admin', + } + end + + it 'should have a non-nil provider' do + expect(provider_class).not_to be_nil + end + + context 'when url is correct' do + before :each do + stub_request(:post, "http://127.0.0.1:35357/v2.0/tokens"). + to_return(:status => 200, + :body => auth_response.to_json, + :headers => {}) + stub_request(:get, "http://127.0.0.1:35357/v3/domains"). + with(:headers => {'X-Auth-Token'=>'TOKEN'}). + to_return(:status => 200, + :body => domains_response.to_json, + :headers => {}) + end + + it 'should create a resource' do + resource = Puppet::Type::Heat_domain_id_setter.new(params) + provider = provider_class.new(resource) + expect(provider.exists?).to be_false + expect(provider.create).to be_nil + end + end + + # What happens if we ask for a domain that does not exist? + context 'when domain cannot be found' do + before :each do + stub_request(:post, "http://127.0.0.1:35357/v2.0/tokens"). + to_return(:status => 200, + :body => auth_response.to_json, + :headers => {}) + stub_request(:get, "http://127.0.0.1:35357/v3/domains"). + with(:headers => {'X-Auth-Token'=>'TOKEN'}). + to_return(:status => 200, + :body => domains_response.to_json, + :headers => {}) + + params.merge!(:domain_name => 'bad_domain_name') + end + + it 'should receive an api error' do + resource = Puppet::Type::Heat_domain_id_setter.new(params) + provider = provider_class.new(resource) + expect(provider.exists?).to be_false + expect { provider.create }.to raise_error KeystoneAPIError, /Unable to find matching domain/ + end + end + + # What happens if we ask for a domain name that results in multiple + # matches? + context 'when there are multiple matching domains' do + before :each do + stub_request(:post, "http://127.0.0.1:35357/v2.0/tokens"). + to_return(:status => 200, + :body => auth_response.to_json, + :headers => {}) + stub_request(:get, "http://127.0.0.1:35357/v3/domains"). + with(:headers => {'X-Auth-Token'=>'TOKEN'}). + to_return(:status => 200, + :body => domains_response.to_json, + :headers => {}) + + params.merge!(:domain_name => 'multiple_matches_domain') + end + + it 'should receive an api error' do + resource = Puppet::Type::Heat_domain_id_setter.new(params) + provider = provider_class.new(resource) + expect(provider.exists?).to be_false + expect { provider.create }.to raise_error KeystoneAPIError, /Found multiple matches for domain name/ + end + end + + # What happens if we pass a bad password? + context 'when password is incorrect' do + before :each do + stub_request(:post, "http://127.0.0.1:35357/v2.0/tokens"). + to_return(:status => 401, + :body => auth_response.to_json, + :headers => {}) + end + + it 'should receive an authentication error' do + resource = Puppet::Type::Heat_domain_id_setter.new(params) + provider = provider_class.new(resource) + expect(provider.exists?).to be_false + expect { provider.create }.to raise_error KeystoneAPIError + end + end + + # What happens if the server is not listening? + context 'when keystone server is unavailable' do + before :each do + stub_request(:post, "http://127.0.0.1:35357/v2.0/tokens").to_raise Errno::ECONNREFUSED + end + + it 'should receive a connection error' do + resource = Puppet::Type::Heat_domain_id_setter.new(params) + provider = provider_class.new(resource) + expect(provider.exists?).to be_false + expect { provider.create }.to raise_error KeystoneConnectionError + end + end + + # What happens if we mistype the hostname? + context 'when keystone server is unknown' do + before :each do + stub_request(:post, "http://127.0.0.1:35357/v2.0/tokens").to_raise SocketError, 'getaddrinfo: Name or service not known' + end + + it 'should receive a connection error' do + resource = Puppet::Type::Heat_domain_id_setter.new(params) + provider = provider_class.new(resource) + expect(provider.exists?).to be_false + expect { provider.create }.to raise_error KeystoneConnectionError + end + end + +end