Add Nova Aggregate support

Bring nova aggregate and availability zones support into puppet-nova.

  - Handle nil values and 0 lengths (Aimon Bustardo)
  - Remove extraneous whitespace on aggregate-create (Aimon Bustardo)

Implements: blueprint aggregate-handling

Change-Id: I9125d573a6a3cf4d444300d3570c4ab394c4ecd8
This commit is contained in:
Thomas Bechtold 2014-01-29 06:45:05 +01:00 committed by Mike Dorman
parent 21f5e9a4bc
commit 08578c920e
5 changed files with 806 additions and 0 deletions

199
lib/puppet/provider/nova.rb Normal file
View File

@ -0,0 +1,199 @@
# Run test ie with: rspec spec/unit/provider/nova_spec.rb
require 'puppet/util/inifile'
class Puppet::Provider::Nova < Puppet::Provider
def self.conf_filename
'/etc/nova/nova.conf'
end
def self.withenv(hash, &block)
saved = ENV.to_hash
hash.each do |name, val|
ENV[name.to_s] = val
end
yield
ensure
ENV.clear
saved.each do |name, val|
ENV[name] = val
end
end
def self.nova_conf
return @nova_conf if @nova_conf
@nova_conf = Puppet::Util::IniConfig::File.new
@nova_conf.read(conf_filename)
@nova_conf
end
def self.nova_credentials
@nova_credentials ||= get_nova_credentials
end
def nova_credentials
self.class.nova_credentials
end
def self.get_nova_credentials
#needed keys for authentication
auth_keys = ['auth_host', 'auth_port', 'auth_protocol',
'admin_tenant_name', 'admin_user', 'admin_password']
conf = nova_conf
if conf and conf['keystone_authtoken'] and
auth_keys.all?{|k| !conf['keystone_authtoken'][k].nil?}
return Hash[ auth_keys.map \
{ |k| [k, conf['keystone_authtoken'][k].strip] } ]
else
raise(Puppet::Error, "File: #{conf_filename} does not contain all " +
"required sections. Nova types will not work if nova is not " +
"correctly configured.")
end
end
def self.get_auth_endpoint
q = nova_credentials
"#{q['auth_protocol']}://#{q['auth_host']}:#{q['auth_port']}/v2.0/"
end
def self.auth_endpoint
@auth_endpoint ||= get_auth_endpoint
end
def self.auth_nova(*args)
q = nova_credentials
authenv = {
:OS_AUTH_URL => self.auth_endpoint,
:OS_USERNAME => q['admin_user'],
:OS_TENANT_NAME => q['admin_tenant_name'],
:OS_PASSWORD => q['admin_password']
}
begin
withenv authenv do
nova(args)
end
rescue Exception => e
if (e.message =~ /\[Errno 111\] Connection refused/) or
(e.message =~ /\(HTTP 400\)/)
sleep 10
withenv authenv do
nova(args)
end
else
raise(e)
end
end
end
def auth_nova(*args)
self.class.auth_nova(args)
end
def self.reset
@nova_conf = nil
@nova_credentials = nil
end
def self.str2hash(s)
#parse string
if s.include? "="
k, v = s.split("=", 2)
return {k.gsub(/'/, "") => v.gsub(/'/, "")}
else
return s.gsub(/'/, "")
end
end
def self.str2list(s)
#parse string
if s.include? ","
if s.include? "="
new = {}
else
new = []
end
s.split(",").each do |el|
ret = str2hash(el.strip())
if s.include? "="
new.update(ret)
else
new.push(ret)
end
end
return new
else
return str2hash(s.strip())
end
end
def self.cliout2list(output)
#don't proceed with empty output
if output.empty?
return []
end
lines = []
output.each_line do |line|
#ignore lines starting with '+'
if not line.match("^\\+")
#split line at '|' and remove useless information
line = line.gsub(/^\| /, "").gsub(/ \|$/, "").gsub(/[\n]+/, "")
line = line.split("|").map do |el|
el.strip().gsub(/^-$/, "")
end
#check every element for list
line = line.map do |el|
el = str2list(el)
end
lines.push(line)
end
end
#create a list of hashes and return the list
hash_list = []
header = lines[0]
lines[1..-1].each do |line|
hash_list.push(Hash[header.zip(line)])
end
return hash_list
end
def self.nova_aggregate_resources_ids
#produce a list of hashes with Id=>Name pairs
lines = []
#run command
cmd_output = auth_nova("aggregate-list")
#parse output
hash_list = cliout2list(cmd_output)
#only interessted in Id and Name
hash_list.map{ |e| e.delete("Availability Zone")}
hash_list.map{ |e| e['Id'] = e['Id'].to_i}
return hash_list
end
def self.nova_aggregate_resources_get_name_by_id(name)
#find the id by the given name
nova_aggregate_resources_ids.each do |entry|
if entry["Name"] == name
return entry["Id"]
end
end
#name not found
return nil
end
def self.nova_aggregate_resources_attr(id)
#run command to get details for given Id
cmd_output = auth_nova("aggregate-details", id)
list = cliout2list(cmd_output)[0]
if ! list["Hosts"].is_a?(Array)
if list["Hosts"] == ""
list["Hosts"] = []
else
list["Hosts"] = [ list["Hosts"] ]
end
end
return list
end
end

View File

@ -0,0 +1,157 @@
require File.join(File.dirname(__FILE__), '..','..','..',
'puppet/provider/nova')
Puppet::Type.type(:nova_aggregate).provide(
:nova,
:parent => Puppet::Provider::Nova
) do
desc "Manage nova aggregations"
commands :nova => 'nova'
mk_resource_methods
def self.instances
nova_aggregate_resources_ids().collect do |el|
attrs = nova_aggregate_resources_attr(el['Id'])
new(
:ensure => :present,
:name => attrs['Name'],
:id => attrs['Id'],
:availability_zone => attrs['Availability Zone'],
:metadata => attrs['Metadata'],
:hosts => attrs['Hosts']
)
end
end
def self.prefetch(resources)
instances_ = instances
resources.keys.each do |name|
if provider = instances_.find{ |instance| instance.name == name }
resources[name].provider = provider
end
end
end
def exists?
@property_hash[:ensure] == :present
end
def destroy
#delete hosts first
if not @property_hash[:hosts].nil?
@property_hash[:hosts].each do |h|
auth_nova("aggregate-remove-host", name, h)
end
end
#now delete aggregate
auth_nova("aggregate-delete", name)
@property_hash[:ensure] = :absent
end
def create
extras = Array.new
#check for availability zone
if not @resource[:availability_zone].nil? and not @resource[:availability_zone].empty?
extras << "#{@resource[:availability_zone]}"
end
#run the command
result = auth_nova("aggregate-create", resource[:name], extras)
#get Id by Name
id = self.class.nova_aggregate_resources_get_name_by_id(resource[:name])
@property_hash = {
:ensure => :present,
:name => resource[:name],
:id => id,
:availability_zone => resource[:availability_zone]
}
#add metadata
if not @resource[:metadata].nil? and not @resource[:metadata].empty?
@resource[:metadata].each do |key, value|
set_metadata_helper(id, key, value)
end
@property_hash[:metadata] = resource[:metadata]
end
#add hosts - This throws an error if the host is already attached to another aggregate!
if not @resource[:hosts].nil? and not @resource[:hosts].empty?
@resource[:hosts].each do |host|
auth_nova("aggregate-add-host", id, "#{host}")
end
@property_hash[:hosts] = resource[:hosts]
end
end
def hosts=(val)
#get current hosts
id = self.class.nova_aggregate_resources_get_name_by_id(name)
attrs = self.class.nova_aggregate_resources_attr(id)
#remove all hosts which are not in new value list
attrs['Hosts'].each do |h|
if not val.include? h
auth_nova("aggregate-remove-host", id, "#{h}")
end
end
#add hosts from the value list
val.each do |h|
if not attrs['Hosts'].include? h
auth_nova("aggregate-add-host", id, "#{h}")
end
end
end
def set_metadata_helper(agg_id, key, value)
auth_nova("aggregate-set-metadata", agg_id, "#{key}=#{value}")
end
def metadata
#get current metadata
id = self.class.nova_aggregate_resources_get_name_by_id(name)
attrs = self.class.nova_aggregate_resources_attr(id)
#just ignore the availability_zone. that's handled directly by nova
attrs['Metadata'].delete('availability_zone')
return attrs['Metadata']
end
def metadata=(val)
#get current metadata
id = self.class.nova_aggregate_resources_get_name_by_id(name)
attrs = self.class.nova_aggregate_resources_attr(id)
#get keys which are in current metadata but not in val. Make sure it has data first!
if attrs['Metadata'].length > 0
obsolete_keys = attrs['Metadata'].keys - val.keys
end
# clear obsolete keys. If there are any!
if obsolete_keys
obsolete_keys.each do |key|
if not key.include? 'availability_zone'
auth_nova("aggregate-set-metadata", id, "#{key}")
end
end
#handle keys (with obsolete keys)
new_keys = val.keys - obsolete_keys
else
#handle keys (without obsolete keys)
new_keys = val.keys
end
#set new metadata if value changed
new_keys.each do |key|
if val[key] != attrs['Metadata'][key.to_s]
value = val[key]
set_metadata_helper(id, key, value)
end
end
end
def availability_zone=(val)
id = self.class.nova_aggregate_resources_get_name_by_id(name)
auth_nova("aggregate-set-metadata", id, "availability_zone=#{val}")
end
end

View File

@ -0,0 +1,110 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2014 Deutsche Telekom AG
#
# Author: Thomas Bechtold <t.bechtold@telekom.de>
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
# nova_aggregate type
#
# == Parameters
# [*name*]
# Name for the new aggregate
# Required
#
# [*availability_zone*]
# The availability zone. ie "zone1"
# Optional
#
# [*metadata*]
# String with key/value pairs. ie "key=value,key=value"
# Optional
#
# [*hosts*]
# A comma seperated list with hosts or a single host. ie "host1,host2"
# Optional
#
require 'puppet'
Puppet::Type.newtype(:nova_aggregate) do
@doc = "Manage creation of nova aggregations."
ensurable
autorequire(:nova_config) do
['auth_host', 'auth_port', 'auth_protocol', 'admin_tenant_name', 'admin_user', 'admin_password']
end
newparam(:name, :namevar => true) do
desc 'Name for the new aggregate'
validate do |value|
if not value.is_a? String
raise ArgumentError, "name parameter must be a String"
end
unless value =~ /[a-z0-9]+/
raise ArgumentError, "#{value} is not a valid name"
end
end
end
newproperty(:id) do
desc 'The unique Id of the aggregate'
validate do |v|
raise ArgumentError, 'This is a read only property'
end
end
newproperty(:availability_zone) do
desc 'The availability zone of the aggregate'
validate do |value|
if not value.is_a? String
raise ArgumentError, "availability zone must be a String"
end
end
end
newproperty(:metadata) do
desc 'The metadata of the aggregate'
#convert DSL/string form to internal form which is a single hash
munge do |value|
internal = Hash.new
value.split(",").map{|el| el.strip()}.each do |pair|
key, value = pair.split("=", 2)
internal[key.strip()] = value.strip()
end
return internal
end
validate do |value|
value.split(",").each do |kv|
raise ArgumentError, "Key/value pairs must be separated by an =" unless value.include?("=")
end
end
end
newproperty(:hosts) do
desc 'Single host or comma seperated list of hosts'
#convert DSL/string form to internal form
munge do |value|
return value.split(",").map{|el| el.strip()}
end
end
validate do
raise ArgumentError, 'Name type must be set' unless self[:name]
end
end

View File

@ -0,0 +1,66 @@
# run with: rspec spec/type/nova_aggregate_spec.rb
require 'spec_helper'
describe Puppet::Type.type(:nova_aggregate) do
before :each do
@provider_class = described_class.provide(:simple) do
mk_resource_methods
def create; end
def delete; end
def exists?; get(:ensure) != :absent; end
def flush; end
def self.instances; []; end
end
end
it "should be able to create an instance" do
described_class.new(:name => 'agg0').should_not be_nil
end
it "should be able to create an more complex instance" do
described_class.new(:name => 'agg0',
:availability_zone => 'myzone',
:metadata => "a=b, c=d",
:hosts => "host1").should_not be_nil
end
it "should be able to create an more complex instance with multiple hosts" do
described_class.new(:name => 'agg0',
:availability_zone => 'myzone',
:metadata => "a=b, c=d",
:hosts => "host1, host2").should_not be_nil
end
it "should be able to create a instance and have the default values" do
c = described_class.new(:name => 'agg0')
c[:name].should == "agg0"
c[:availability_zone].should == nil
c[:metadata].should == nil
c[:hosts].should == nil
end
it "should return the given values" do
c = described_class.new(:name => 'agg0',
:availability_zone => 'myzone',
:metadata => " a = b , c= d ",
:hosts => " host1, host2 ")
c[:name].should == "agg0"
c[:availability_zone].should == "myzone"
c[:metadata].should == {"a" => "b", "c" => "d"}
c[:hosts].should == ["host1" , "host2"]
end
it "should return the given values" do
c = described_class.new(:name => 'agg0',
:availability_zone => "",
:metadata => "",
:hosts => "")
c[:name].should == "agg0"
c[:availability_zone].should == ""
c[:metadata].should == {}
c[:hosts].should == []
end
end

View File

@ -0,0 +1,274 @@
require 'puppet'
require 'spec_helper'
require 'puppet/provider/nova'
require 'rspec/mocks'
describe Puppet::Provider::Nova do
def klass
described_class
end
let :credential_hash do
{
'auth_host' => '192.168.56.210',
'auth_port' => '35357',
'auth_protocol' => 'https',
'admin_tenant_name' => 'admin_tenant',
'admin_user' => 'admin',
'admin_password' => 'password',
}
end
let :auth_endpoint do
'https://192.168.56.210:35357/v2.0/'
end
let :credential_error do
/Nova types will not work/
end
after :each do
klass.reset
end
describe 'when determining credentials' do
it 'should fail if config is empty' do
conf = {}
klass.expects(:nova_conf).returns(conf)
expect do
klass.nova_credentials
end.to raise_error(Puppet::Error, credential_error)
end
it 'should fail if config does not have keystone_authtoken section.' do
conf = {'foo' => 'bar'}
klass.expects(:nova_conf).returns(conf)
expect do
klass.nova_credentials
end.to raise_error(Puppet::Error, credential_error)
end
it 'should fail if config does not contain all auth params' do
conf = {'keystone_authtoken' => {'invalid_value' => 'foo'}}
klass.expects(:nova_conf).returns(conf)
expect do
klass.nova_credentials
end.to raise_error(Puppet::Error, credential_error)
end
it 'should use specified host/port/protocol in the auth endpoint' do
conf = {'keystone_authtoken' => credential_hash}
klass.expects(:nova_conf).returns(conf)
klass.get_auth_endpoint.should == auth_endpoint
end
end
describe 'when invoking the nova cli' do
it 'should set auth credentials in the environment' do
authenv = {
:OS_AUTH_URL => auth_endpoint,
:OS_USERNAME => credential_hash['admin_user'],
:OS_TENANT_NAME => credential_hash['admin_tenant_name'],
:OS_PASSWORD => credential_hash['admin_password'],
}
klass.expects(:get_nova_credentials).with().returns(credential_hash)
klass.expects(:withenv).with(authenv)
klass.auth_nova('test_retries')
end
['[Errno 111] Connection refused',
'(HTTP 400)'].reverse.each do |valid_message|
it "should retry when nova cli returns with error #{valid_message}" do
klass.expects(:get_nova_credentials).with().returns({})
klass.expects(:sleep).with(10).returns(nil)
klass.expects(:nova).twice.with(['test_retries']).raises(
Exception, valid_message).then.returns('')
klass.auth_nova('test_retries')
end
end
end
describe 'when parse a string line' do
it 'should return the same string' do
res = klass.str2hash("zone1")
res.should == "zone1"
end
it 'should return the string without quotes' do
res = klass.str2hash("'zone1'")
res.should == "zone1"
end
it 'should return the same string' do
res = klass.str2hash("z o n e1")
res.should == "z o n e1"
end
it 'should return a hash' do
res = klass.str2hash("a=b")
res.should == {"a"=>"b"}
end
it 'should return a hash with containing spaces' do
res = klass.str2hash("a b = c d")
res.should == {"a b "=>" c d"}
end
it 'should return the same string' do
res = klass.str2list("zone1")
res.should == "zone1"
end
it 'should return a list of strings' do
res = klass.str2list("zone1, zone2")
res.should == ["zone1", "zone2"]
end
it 'should return a list of strings' do
res = klass.str2list("zo n e1, zone2 ")
res.should == ["zo n e1", "zone2"]
end
it 'should return a hash with multiple keys' do
res = klass.str2list("a=b, c=d")
res.should == {"a"=>"b", "c"=>"d"}
end
it 'should return a single hash' do
res = klass.str2list("a=b")
res.should == {"a"=>"b"}
end
end
describe 'when parsing cli output' do
it 'should return a list with hashes' do
output = <<-EOT
+----+-------+-------------------+
| Id | Name | Availability Zone |
+----+-------+-------------------+
| 1 | haha | haha2 |
| 2 | haha2 | - |
+----+-------+-------------------+
EOT
res = klass.cliout2list(output)
res.should == [{"Id"=>"1", "Name"=>"haha", "Availability Zone"=>"haha2"},
{"Id"=>"2", "Name"=>"haha2", "Availability Zone"=>""}]
end
it 'should return a list with hashes' do
output = <<-EOT
+----+-------+-------------------+-------+--------------------------------------------------+
| Id | Name | Availability Zone | Hosts | Metadata |
+----+-------+-------------------+-------+--------------------------------------------------+
| 16 | agg94 | my_-zone1 | | 'a=b', 'availability_zone= my_-zone1', 'x_q-r=y' |
+----+-------+-------------------+-------+--------------------------------------------------+
EOT
res = klass.cliout2list(output)
res.should == [{"Id"=>"16",
"Name"=>"agg94",
"Availability Zone"=>"my_-zone1",
"Hosts"=>"",
"Metadata"=> {
"a"=>"b",
"availability_zone"=>" my_-zone1",
"x_q-r"=>"y"
}
}]
end
it 'should return a empty list' do
output = <<-EOT
+----+------+-------------------+
| Id | Name | Availability Zone |
+----+------+-------------------+
+----+------+-------------------+
EOT
res = klass.cliout2list(output)
res.should == []
end
it 'should return a empty list because no input available' do
output = <<-EOT
EOT
res = klass.cliout2list(output)
res.should == []
end
it 'should return a list with hashes' do
output = <<-EOT
+----+----------------+-------------------+
| Id | Name | Availability Zone |
+----+----------------+-------------------+
| 6 | my | zone1 |
| 8 | my2 | - |
+----+----------------+-------------------+
EOT
res = klass.cliout2list(output)
res.should == [{"Id"=>"6", "Name"=>"my", "Availability Zone"=>"zone1"},
{"Id"=>"8", "Name"=>"my2", "Availability Zone"=>""}]
end
end
describe 'when handling cli output' do
it 'should return the availble Id' do
output = <<-EOT
+----+-------+-------------------+
| Id | Name | Availability Zone |
+----+-------+-------------------+
| 1 | haha | haha2 |
| 2 | haha2 | - |
+----+-------+-------------------+
EOT
klass.expects(:auth_nova).returns(output)
res = klass.nova_aggregate_resources_get_name_by_id("haha2")
res.should eql(2)
end
it 'should return nil because given name is not available' do
output = <<-EOT
+----+-------+-------------------+
| Id | Name | Availability Zone |
+----+-------+-------------------+
| 1 | haha | haha2 |
| 2 | haha2 | - |
+----+-------+-------------------+
EOT
klass.expects(:auth_nova).returns(output)
res = klass.nova_aggregate_resources_get_name_by_id("notavailable")
res.should eql(nil)
end
end
describe 'when getting details for given Id' do
it 'should return a Hash with the details' do
output = <<-EOT
+----+-------+-------------------+-------+--------------------------------------------------+
| Id | Name | Availability Zone | Hosts | Metadata |
+----+-------+-------------------+-------+--------------------------------------------------+
| 16 | agg94 | my_-zone1 | | 'a=b', 'availability_zone= my_-zone1', 'x_q-r=y' |
+----+-------+-------------------+-------+--------------------------------------------------+
EOT
klass.expects(:auth_nova).returns(output)
res = klass.nova_aggregate_resources_attr(16)
res.should == {
"Id"=>"16",
"Name"=>"agg94",
"Availability Zone"=>"my_-zone1",
"Hosts"=>[],
"Metadata"=>{
"a"=>"b",
"availability_zone"=>" my_-zone1",
"x_q-r"=>"y"
}
}
end
end
end