diff --git a/lib/puppet/provider/nova.rb b/lib/puppet/provider/nova.rb new file mode 100644 index 000000000..ca7bb0181 --- /dev/null +++ b/lib/puppet/provider/nova.rb @@ -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 diff --git a/lib/puppet/provider/nova_aggregate/nova.rb b/lib/puppet/provider/nova_aggregate/nova.rb new file mode 100644 index 000000000..cad8a67e3 --- /dev/null +++ b/lib/puppet/provider/nova_aggregate/nova.rb @@ -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 diff --git a/lib/puppet/type/nova_aggregate.rb b/lib/puppet/type/nova_aggregate.rb new file mode 100644 index 000000000..ccc6b9c5e --- /dev/null +++ b/lib/puppet/type/nova_aggregate.rb @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2014 Deutsche Telekom AG +# +# Author: Thomas Bechtold +# +# 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 diff --git a/spec/type/nova_aggregate_spec.rb b/spec/type/nova_aggregate_spec.rb new file mode 100644 index 000000000..dc5db974a --- /dev/null +++ b/spec/type/nova_aggregate_spec.rb @@ -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 diff --git a/spec/unit/provider/nova_spec.rb b/spec/unit/provider/nova_spec.rb new file mode 100644 index 000000000..6f757e746 --- /dev/null +++ b/spec/unit/provider/nova_spec.rb @@ -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