diff --git a/README.md b/README.md index 4f9f8d52..8deb3209 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,10 @@ The provider needs to specify the `remote_resource` feature to enable the second After this, `puppet device` will be able to use the new provider, and supply it (through the device class) with the URL specified in the [`device.conf`](https://puppet.com/docs/puppet/5.3/config_file_device.html). +#### Device-specific providers + +To allow modules to deal with different backends independently of each other, the Resource API also implements a mechanism to use different API providers side-by-side. For a given device type (see above), the Resource API will first try to load a `Puppet::Provider::TypeName::DeviceType` class from `lib/puppet/provider/type_name/device_type.rb`, before falling back to the regular provider at `Puppet::Provider::TypeName::TypeName`. + ### Further Reading The [Resource API](https://github.com/puppetlabs/puppet-specifications/blob/master/language/resource-api/README.md) describes details of all the capabilities of this gem. diff --git a/lib/puppet/resource_api.rb b/lib/puppet/resource_api.rb index fdc175e8..84e6d887 100644 --- a/lib/puppet/resource_api.rb +++ b/lib/puppet/resource_api.rb @@ -4,6 +4,7 @@ require 'puppet/resource_api/type_definition' require 'puppet/resource_api/version' require 'puppet/type' +require 'puppet/util/network_device' module Puppet::ResourceApi @warning_count = 0 @@ -54,7 +55,8 @@ def register_type(definition) # Keeps a copy of the provider around. Weird naming to avoid clashes with puppet's own `provider` member define_singleton_method(:my_provider) do - @my_provider ||= Puppet::ResourceApi.load_provider(definition[:name]).new + @my_provider ||= Hash.new { |hash, key| hash[key] = Puppet::ResourceApi.load_provider(definition[:name]).new } + @my_provider[Puppet::Util::NetworkDevice.current.class] end # make the provider available in the instance's namespace @@ -514,14 +516,48 @@ def self.parse_title_patterns(patterns) def load_provider(type_name) class_name = class_name_from_type_name(type_name) type_name_sym = type_name.to_sym + device_name = if Puppet::Util::NetworkDevice.current.nil? + nil + else + # extract the device type from the currently loaded device's class + Puppet::Util::NetworkDevice.current.class.name.split('::')[-2].downcase + end + device_class_name = class_name_from_type_name(device_name) + + if device_name + device_name_sym = device_name.to_sym if device_name + load_device_provider(class_name, type_name_sym, device_class_name, device_name_sym) + else + load_default_provider(class_name, type_name_sym) + end + rescue NameError + if device_name # line too long # rubocop:disable Style/GuardClause + raise Puppet::DevError, "Found neither the device-specific provider class Puppet::Provider::#{class_name}::#{device_class_name} in puppet/provider/#{type_name}/#{device_name}"\ + " nor the generic provider class Puppet::Provider::#{class_name}::#{class_name} in puppet/provider/#{type_name}/#{type_name}" + else + raise Puppet::DevError, "provider class Puppet::Provider::#{class_name}::#{class_name} not found in puppet/provider/#{type_name}/#{type_name}" + end + end + module_function :load_provider # rubocop:disable Style/AccessModifierDeclarations + def load_default_provider(class_name, type_name_sym) # loads the "puppet/provider/#{type_name}/#{type_name}" file through puppet Puppet::Type.type(type_name_sym).provider(type_name_sym) Puppet::Provider.const_get(class_name).const_get(class_name) - rescue NameError - raise Puppet::DevError, "class #{class_name} not found in puppet/provider/#{type_name}/#{type_name}" end - module_function :load_provider # rubocop:disable Style/AccessModifierDeclarations + module_function :load_default_provider # rubocop:disable Style/AccessModifierDeclarations + + def load_device_provider(class_name, type_name_sym, device_class_name, device_name_sym) + # loads the "puppet/provider/#{type_name}/#{device_name}" file through puppet + Puppet::Type.type(type_name_sym).provider(device_name_sym) + provider_module = Puppet::Provider.const_get(class_name) + if provider_module.const_defined?(device_class_name) + provider_module.const_get(device_class_name) + else + load_default_provider(class_name, type_name_sym) + end + end + module_function :load_device_provider # rubocop:disable Style/AccessModifierDeclarations def self.class_name_from_type_name(type_name) type_name.to_s.split('_').map(&:capitalize).join diff --git a/spec/acceptance/multi_device_spec.rb b/spec/acceptance/multi_device_spec.rb new file mode 100644 index 00000000..df4a1882 --- /dev/null +++ b/spec/acceptance/multi_device_spec.rb @@ -0,0 +1,92 @@ +require 'open3' +require 'puppet/version' +require 'spec_helper' +require 'tempfile' + +RSpec.describe 'exercising a type with device-specific providers' do + let(:common_args) { '--verbose --trace --strict=error --modulepath spec/fixtures' } + + before(:all) do + FileUtils.mkdir_p(File.expand_path('~/.puppetlabs/opt/puppet/cache/devices/some_node/state')) + FileUtils.mkdir_p(File.expand_path('~/.puppetlabs/opt/puppet/cache/devices/other_node/state')) + end + + describe 'using `puppet device`' do + let(:common_args) { super() + " --deviceconfig #{device_conf.path} --target some_node --target other_node" } + let(:device_conf) { Tempfile.new('device.conf') } + let(:device_conf_content) do + <= Gem::Version.new('5.3.6') && Gem::Version.new(Puppet::PUPPETVERSION) != Gem::Version.new('5.4.0') + end + + before(:each) do + skip "No device --apply in puppet before v5.3.6 nor in v5.4.0 (v#{Puppet::PUPPETVERSION} is installed)" unless is_device_apply_supported? + device_conf.write(device_conf_content) + device_conf.close + end + + after(:each) do + device_conf.unlink + end + + it 'applies a catalog successfully' do + pending "can't really test this without a puppetserver; when initially implementing this, it was tested using a hacked `puppet device` command allowing multiple --target params" + + # diff --git a/lib/puppet/application/device.rb b/lib/puppet/application/device.rb + # index 5e7a5cd473..2d39527b47 100644 + # --- a/lib/puppet/application/device.rb + # +++ b/lib/puppet/application/device.rb + # @@ -70,7 +70,8 @@ class Puppet::Application::Device < Puppet::Application + # end + + # option("--target DEVICE", "-t") do |arg| + # - options[:target] = arg.to_s + # + options[:target] ||= [] + # + options[:target] << arg.to_s + # end + + # def summary + # @@ -232,7 +233,7 @@ Licensed under the Apache 2.0 License + # require 'puppet/util/network_device/config' + # devices = Puppet::Util::NetworkDevice::Config.devices.dup + # if options[:target] + # - devices.select! { |key, value| key == options[:target] } + # + devices.select! { |key, value| options[:target].include? key } + # end + # if devices.empty? + # if options[:target] + + # david@davids:~/git/puppet-resource_api$ bundle exec puppet device --verbose --trace --strict=error --modulepath spec/fixtures \ + # --target some_node --target other_node --resource multi_device multi_device multi_device + # ["multi_device", "multi_device", "multi_device"] + # Info: retrieving resource: multi_device from some_node at file:///etc/credentials.txt + # multi_device { 'multi_device': + # ensure => 'absent', + # } + # ["multi_device"] + # Info: retrieving resource: multi_device from other_node at file:///etc/credentials.txt + + # david@davids:~/git/puppet-resource_api$ + + Tempfile.create('apply_success') do |f| + f.write 'multi_device { "foo": }' + f.close + + stdout_str, _status = Open3.capture2e("puppet device #{common_args} --apply #{f.path}") + expect(stdout_str).to match %r{Compiled catalog for some_node} + expect(stdout_str).to match %r{Compiled catalog for other_node} + expect(stdout_str).not_to match %r{Error:} + end + end + end +end diff --git a/spec/fixtures/test_module/lib/puppet/provider/multi_device/multi_device.rb b/spec/fixtures/test_module/lib/puppet/provider/multi_device/multi_device.rb new file mode 100644 index 00000000..c6b779dc --- /dev/null +++ b/spec/fixtures/test_module/lib/puppet/provider/multi_device/multi_device.rb @@ -0,0 +1,21 @@ +require 'puppet/resource_api/simple_provider' + +# Implementation for the multi_device type using the Resource API. +# default provider class +class Puppet::Provider::MultiDevice::MultiDevice < Puppet::ResourceApi::SimpleProvider + def get(_context) + [] + end + + def create(context, name, should) + context.notice("Creating '#{name}' with #{should.inspect}") + end + + def update(context, name, should) + context.notice("Updating '#{name}' with #{should.inspect}") + end + + def delete(context, name) + context.notice("Deleting '#{name}'") + end +end diff --git a/spec/fixtures/test_module/lib/puppet/provider/multi_device/other_device.rb b/spec/fixtures/test_module/lib/puppet/provider/multi_device/other_device.rb new file mode 100644 index 00000000..0c0469d9 --- /dev/null +++ b/spec/fixtures/test_module/lib/puppet/provider/multi_device/other_device.rb @@ -0,0 +1,21 @@ +require 'puppet/resource_api/simple_provider' + +# Implementation for the multi_device type using the Resource API. +# device-specific class for `other_device` +class Puppet::Provider::MultiDevice::OtherDevice < Puppet::ResourceApi::SimpleProvider + def get(_context) + [] + end + + def create(context, name, should) + context.notice("Creating '#{name}' with #{should.inspect}") + end + + def update(context, name, should) + context.notice("Updating '#{name}' with #{should.inspect}") + end + + def delete(context, name) + context.notice("Deleting '#{name}'") + end +end diff --git a/spec/fixtures/test_module/lib/puppet/provider/multi_device/some_device.rb b/spec/fixtures/test_module/lib/puppet/provider/multi_device/some_device.rb new file mode 100644 index 00000000..243397a0 --- /dev/null +++ b/spec/fixtures/test_module/lib/puppet/provider/multi_device/some_device.rb @@ -0,0 +1,21 @@ +require 'puppet/resource_api/simple_provider' + +# Implementation for the multi_device type using the Resource API. +# device-specific class for `some_device` +class Puppet::Provider::MultiDevice::SomeDevice < Puppet::ResourceApi::SimpleProvider + def get(_context) + [] + end + + def create(context, name, should) + context.notice("Creating '#{name}' with #{should.inspect}") + end + + def update(context, name, should) + context.notice("Updating '#{name}' with #{should.inspect}") + end + + def delete(context, name) + context.notice("Deleting '#{name}'") + end +end diff --git a/spec/fixtures/test_module/lib/puppet/type/multi_device.rb b/spec/fixtures/test_module/lib/puppet/type/multi_device.rb new file mode 100644 index 00000000..732b0b4a --- /dev/null +++ b/spec/fixtures/test_module/lib/puppet/type/multi_device.rb @@ -0,0 +1,21 @@ +require 'puppet/resource_api' + +Puppet::ResourceApi.register_type( + name: 'multi_device', + docs: <<-EOS, + This type provides Puppet with the capabilities to manage ... + EOS + features: [ 'remote_resource' ], + attributes: { + ensure: { + type: 'Enum[present, absent]', + desc: 'Whether this resource should be present or absent on the target system.', + default: 'present', + }, + name: { + type: 'String', + desc: 'The name of the resource you want to manage.', + behaviour: :namevar, + }, + }, +) diff --git a/spec/fixtures/test_module/lib/puppet/util/network_device/other_device/device.rb b/spec/fixtures/test_module/lib/puppet/util/network_device/other_device/device.rb new file mode 100644 index 00000000..055a651a --- /dev/null +++ b/spec/fixtures/test_module/lib/puppet/util/network_device/other_device/device.rb @@ -0,0 +1,10 @@ +require 'puppet/util/network_device/simple/device' + +module Puppet::Util::NetworkDevice::Other_device # rubocop:disable Style/ClassAndModuleCamelCase + # A simple test device returning hardcoded facts + class Device < Puppet::Util::NetworkDevice::Simple::Device + def facts + { 'foo' => 'bar' } + end + end +end diff --git a/spec/fixtures/test_module/lib/puppet/util/network_device/some_device/device.rb b/spec/fixtures/test_module/lib/puppet/util/network_device/some_device/device.rb new file mode 100644 index 00000000..b881af03 --- /dev/null +++ b/spec/fixtures/test_module/lib/puppet/util/network_device/some_device/device.rb @@ -0,0 +1,10 @@ +require 'puppet/util/network_device/simple/device' + +module Puppet::Util::NetworkDevice::Some_device # rubocop:disable Style/ClassAndModuleCamelCase + # A simple test device returning hardcoded facts + class Device < Puppet::Util::NetworkDevice::Simple::Device + def facts + { 'foo' => 'bar' } + end + end +end diff --git a/spec/fixtures/test_module/spec/unit/puppet/provider/multi_device/multi_device_spec.rb b/spec/fixtures/test_module/spec/unit/puppet/provider/multi_device/multi_device_spec.rb new file mode 100644 index 00000000..29a624bb --- /dev/null +++ b/spec/fixtures/test_module/spec/unit/puppet/provider/multi_device/multi_device_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' + +ensure_module_defined('Puppet::Provider::MultiDevice') +require 'puppet/provider/multi_device/multi_device' + +RSpec.describe Puppet::Provider::MultiDevice::MultiDevice do + subject(:provider) { described_class.new } + + let(:context) { instance_double('Puppet::ResourceApi::BaseContext', 'context') } + + describe '#get' do + it 'processes resources' do + expect(provider.get(context)).to eq [ + { + name: 'foo', + ensure: 'present', + }, + { + name: 'bar', + ensure: 'present', + }, + ] + end + end + + describe 'create(context, name, should)' do + it 'creates the resource' do + expect(context).to receive(:notice).with(%r{\ACreating 'a'}) + + provider.create(context, 'a', name: 'a', ensure: 'present') + end + end + + describe 'update(context, name, should)' do + it 'updates the resource' do + expect(context).to receive(:notice).with(%r{\AUpdating 'foo'}) + + provider.update(context, 'foo', name: 'foo', ensure: 'present') + end + end + + describe 'delete(context, name, should)' do + it 'deletes the resource' do + expect(context).to receive(:notice).with(%r{\ADeleting 'foo'}) + + provider.delete(context, 'foo') + end + end +end diff --git a/spec/fixtures/test_module/spec/unit/puppet/type/multi_device_spec.rb b/spec/fixtures/test_module/spec/unit/puppet/type/multi_device_spec.rb new file mode 100644 index 00000000..fab8f76c --- /dev/null +++ b/spec/fixtures/test_module/spec/unit/puppet/type/multi_device_spec.rb @@ -0,0 +1,8 @@ +require 'spec_helper' +require 'puppet/type/multi_device' + +RSpec.describe 'the multi_device type' do + it 'loads' do + expect(Puppet::Type.type(:multi_device)).not_to be_nil + end +end diff --git a/spec/puppet/resource_api_spec.rb b/spec/puppet/resource_api_spec.rb index e0b40450..30b8f03a 100644 --- a/spec/puppet/resource_api_spec.rb +++ b/spec/puppet/resource_api_spec.rb @@ -1253,7 +1253,7 @@ def set(_context, _changes); end context 'when loading a provider that doesn\'t create the correct class' do let(:definition) { { name: 'no_class', attributes: {} } } - it { expect { described_class.load_provider('no_class') }.to raise_error Puppet::DevError, %r{NoClass} } + it { expect { described_class.load_provider('no_class') }.to raise_error Puppet::DevError, %r{Puppet::Provider::NoClass::NoClass} } end context 'when loading a provider that creates the correct class' do @@ -1261,6 +1261,42 @@ def set(_context, _changes); end it { expect(described_class.load_provider('test_provider').name).to eq 'Puppet::Provider::TestProvider::TestProvider' } end + + context 'with a device configured' do + let(:definition) { { name: 'multi_provider', attributes: {} } } + let(:device) { instance_double('Puppet::Util::NetworkDevice::Simple::Device', 'device') } + let(:device_class) { instance_double(Class, 'device_class') } + + before(:each) do + allow(Puppet::Util::NetworkDevice).to receive(:current).with(no_args).and_return(device) + allow(device).to receive(:class).with(no_args).and_return(device_class) + allow(device_class).to receive(:name).with(no_args).and_return(device_class_name) + + module ::Puppet::Provider::MultiProvider + class MultiProvider; end + class SomeDevice; end + class OtherDevice; end + end + end + + context 'with no provider' do + let(:device_class_name) { 'Puppet::Util::NetworkDevice::Some_device::Device' } + + it { expect { described_class.load_provider('no_class') }.to raise_error Puppet::DevError, %r{device-specific provider class Puppet::Provider::NoClass::SomeDevice} } + end + + context 'with no device-specific provider' do + let(:device_class_name) { 'Puppet::Util::NetworkDevice::Default_device::Device' } + + it('loads the default provider') { expect(described_class.load_provider('multi_provider').name).to eq 'Puppet::Provider::MultiProvider::MultiProvider' } + end + + context 'with a device-specific provider' do + let(:device_class_name) { 'Puppet::Util::NetworkDevice::Some_device::Device' } + + it('loads the device provider') { expect(described_class.load_provider('multi_provider').name).to eq 'Puppet::Provider::MultiProvider::SomeDevice' } + end + end end context 'with a provider that does canonicalization', agent_test: true do