diff --git a/.rubocop.yml b/.rubocop.yml index 6d831a74..5457a9ea 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,6 +1,5 @@ --- require: rubocop-rspec -inherit_from: .rubocop_todo.yml AllCops: TargetRubyVersion: '2.1' Include: @@ -157,4 +156,4 @@ Naming/UncommunicativeMethodParamName: # This cop breaks syntax highlighting in VSCode Layout/ClosingHeredocIndentation: - Enabled: false \ No newline at end of file + Enabled: false diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml deleted file mode 100644 index 63295575..00000000 --- a/.rubocop_todo.yml +++ /dev/null @@ -1,17 +0,0 @@ -# This configuration was generated by -# `rubocop --auto-gen-config` -# on 2017-09-05 11:31:11 +0100 using RuboCop version 0.49.1. -# The point is for the user to remove these configuration records -# one by one as the offenses are removed from the code base. -# Note that changes in the inspected code, or installation of new -# versions of RuboCop, may require this file to be generated again. - -# Offense count: 6 -Style/Documentation: - Exclude: - - 'spec/**/*' - - 'test/**/*' - - 'lib/puppet/resource_api.rb' - - 'lib/puppet/resource_api/base_context.rb' - - 'lib/puppet/resource_api/io_context.rb' - - 'lib/puppet/resource_api/puppet_context.rb' diff --git a/.travis.yml b/.travis.yml index b2619621..7b214550 100644 --- a/.travis.yml +++ b/.travis.yml @@ -25,7 +25,7 @@ matrix: - env: RVM="jruby-9.1.9.0" PUPPET_GEM_VERSION='~> 5' JRUBY_OPTS="--debug" before_cache: pushd ~/.rvm && rm -rf archives rubies/ruby-2.2.7 rubies/ruby-2.3.4 && popd cache: - bundler: true + bundler: false directories: ~/.rvm before_install: rvm use jruby-9.1.9.0 --install --binary --fuzzy - rvm: 2.4.3 @@ -65,10 +65,5 @@ matrix: - rvm: 2.5.1 env: PUPPET_GEM_VERSION='https://github.com/puppetlabs/puppet.git#6.0.x' notifications: - hipchat: - rooms: - secure: 10a49kkZcghKHNnef8x7eBG+KjScL3i1VpygFg6DPAOK2YNbEoyEx1Kv9KLC7GSRYov/SQZOsZrvHZtDhEtFSKhhiAjOwxl1jV1t6aAMGMnN1IoZBOvdAJKrZsm54/bBeYp+je2wqnnoFNtLVFSoOX0LkFaDEWT+zGZ5xKJIH25GpeQEZf1eDxs/d8YX/m+RwbGXHVA//hOpvZo0ntvznh2EbW5OPODKSeUXbWZ+W4ndODTsKWFc/WLMSSgFDzW/Y2/9V40D4IC8lvSx6eKFryMfAQy6pO/d1aTB468awzyVcdYAMMCOITm7hlKGRKxNgq6NkOsXs5KLg6ifpn+a/Rhapbz6Qxbpjjho/7Wxngl4B3T+i35ap/mFrS/fOfKCq3gEQlYn29its9bEFArNGbr+/sXKABb+sRpgW4RTPWYDHJyHJendbevd5tZ+fd0JUBOi0Cb4PcXxQxM8IQrbuu2zso0K5MV05kL0S1DE/VsuUrPaK0RsF+b1+i6NfvtN8kgbYs1eiVku+guIG2ec3xIefQ1hsEOFPFNqSDfHp7nANnRVIbBCt8qw8DhmNEczsfN5Dp21euJUsO9qpau++NzD3jRhkE5Zki5cwsakU7hIQzw82BIb0eSQJCHygieExeEboWRqtDgy/IKIWPgIvEuU68ppR2bl2reKCHLCnWc= - template: - - '%{repository}#%{build_number} (%{branch} - %{commit} : %{author}): %{message} - (Details | PR/Diff)' - format: html + slack: + secure: aPXZYNow8LsmmlS8PQU3FjL0bc7FqUUA95d++wZfIu7YAjGboIUiekxYouQ0XnY+Aig8InvbTOIgBHgGNheyr/YFbFS90/jtulbF8oW7BitW+imgjeAHDCwlQZTCc4FFYde/2pI7QTT8H5NpLR9mKxlTU77Sqr8gFAIybuPdHcKMYQZdEZS07ma2pUp7+GyKS6PDQpzW2+mDCz/wfi3/JdsUvc0mclCZ8vxySc66j5P1E6nFDMzuakBOjwJHpgeDpreapbmSUQLAX0a3ZsFP+N+SNduLotlV2BWnJK2gcO6rGFP4Fz1D0bGXuBnYYdIiB+9OgI3wtXg9y1SifNHUG3IrOBAA8CGNyrebTGKtH0TS2O+HZLbaNX2g6udD5e3156vys9wScmJuQ/rSkVtQfXf1qUm5eijvlXI+DIbssbZHqm6QQGyM4p3NoULmNmF1C85bQoZ4GF7b1P/8mstsVE/HUfnzRPNbwD0r6j1aE/ck3PKMi7ZAhIi0Ja9RnAgP3wi0t62uERYcJGGYEycWohMWnrf2w6GFwGeuoiwAkASdHOLX0/AOMPc4mBOjlc621o8uYMrrZqfF5CrOAvJ151USSsWn2AhXaibIvnHo6X91paNvvNpU/GYu3CUAl6q8OhYovvjtRVPVnhs2DrpgoRB+6NWHnzjRG/wr6Z9U+vA= diff --git a/lib/puppet/resource_api.rb b/lib/puppet/resource_api.rb index 07b97edf..5e84b628 100644 --- a/lib/puppet/resource_api.rb +++ b/lib/puppet/resource_api.rb @@ -5,12 +5,15 @@ require 'puppet/resource_api/property' require 'puppet/resource_api/puppet_context' unless RUBY_PLATFORM == 'java' require 'puppet/resource_api/read_only_parameter' +require 'puppet/resource_api/transport' +require 'puppet/resource_api/transport/wrapper' require 'puppet/resource_api/type_definition' require 'puppet/resource_api/value_creator' require 'puppet/resource_api/version' require 'puppet/type' require 'puppet/util/network_device' +# This module contains the main API to register and access types, providers and transports. module Puppet::ResourceApi @warning_count = 0 @@ -38,7 +41,12 @@ 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 ||= Hash.new { |hash, key| hash[key] = Puppet::ResourceApi.load_provider(definition[:name]).new } - @my_provider[Puppet::Util::NetworkDevice.current.class] + + if Puppet::Util::NetworkDevice.current.is_a? Puppet::ResourceApi::Transport::Wrapper + @my_provider[Puppet::Util::NetworkDevice.current.transport.class] + else + @my_provider[Puppet::Util::NetworkDevice.current.class] + end end # make the provider available in the instance's namespace @@ -352,7 +360,7 @@ def cache_current_state(resource_hash) end define_singleton_method(:context) do - @context ||= PuppetContext.new(definition) + @context ||= PuppetContext.new(TypeDefinition.new(definition)) end def context @@ -410,8 +418,10 @@ def load_provider(type_name) type_name_sym = type_name.to_sym device_name = if Puppet::Util::NetworkDevice.current.nil? nil - else + elsif Puppet::Util::NetworkDevice.current.is_a? Puppet::ResourceApi::Transport::Wrapper # extract the device type from the currently loaded device's class + Puppet::Util::NetworkDevice.current.schema.name + else Puppet::Util::NetworkDevice.current.class.name.split('::')[-2].downcase end device_class_name = class_name_from_type_name(device_name) @@ -451,6 +461,12 @@ def load_device_provider(class_name, type_name_sym, device_class_name, device_na end module_function :load_device_provider # rubocop:disable Style/AccessModifierDeclarations + # keeps the existing register API format. e.g. Puppet::ResourceApi.register_type + def register_transport(schema) + Puppet::ResourceApi::Transport.register(schema) + end + module_function :register_transport # rubocop:disable Style/AccessModifierDeclarations + def self.class_name_from_type_name(type_name) type_name.to_s.split('_').map(&:capitalize).join end diff --git a/lib/puppet/resource_api/base_context.rb b/lib/puppet/resource_api/base_context.rb index bf877680..1c44d1f0 100644 --- a/lib/puppet/resource_api/base_context.rb +++ b/lib/puppet/resource_api/base_context.rb @@ -1,20 +1,34 @@ require 'puppet/resource_api/type_definition' +# rubocop:disable Style/Documentation module Puppet; end module Puppet::ResourceApi; end +# rubocop:enable Style/Documentation + +# This class provides access to all common external dependencies of providers and transports. +# The runtime environment will inject an appropriate implementation. class Puppet::ResourceApi::BaseContext attr_reader :type def initialize(definition) - raise ArgumentError, 'BaseContext requires definition to be a Hash' unless definition.is_a?(Hash) - @typename = definition[:name] - @type = Puppet::ResourceApi::TypeDefinition.new(definition) + if definition.is_a?(Hash) + # this is only for backwards compatibility + @type = Puppet::ResourceApi::TypeDefinition.new(definition) + elsif definition.is_a? Puppet::ResourceApi::BaseTypeDefinition + @type = definition + else + raise ArgumentError, 'BaseContext requires definition to be a child of Puppet::ResourceApi::BaseTypeDefinition, not <%{actual_type}>' % { actual_type: definition.class } + end end def device raise 'Received device() on an unprepared BaseContext. Use a PuppetContext instead.' end + def transport + raise 'No transport available.' + end + def failed? @failed end @@ -27,7 +41,7 @@ def feature_support?(feature) [:debug, :info, :notice, :warning, :err].each do |level| define_method(level) do |*args| if args.length == 1 - message = "#{@context || @typename}: #{args.last}" + message = "#{@context || @type.name}: #{args.last}" elsif args.length == 2 resources = format_titles(args.first) message = "#{resources}: #{args.last}" @@ -137,9 +151,9 @@ def send_log(_level, _message) def format_titles(titles) if titles.length.zero? && !titles.is_a?(String) - @typename + @type.name else - "#{@typename}[#{[titles].flatten.compact.join(', ')}]" + "#{@type.name}[#{[titles].flatten.compact.join(', ')}]" end end diff --git a/lib/puppet/resource_api/io_context.rb b/lib/puppet/resource_api/io_context.rb index 3cfb4afb..3efdde63 100644 --- a/lib/puppet/resource_api/io_context.rb +++ b/lib/puppet/resource_api/io_context.rb @@ -1,9 +1,14 @@ require 'puppet/resource_api/base_context' +# Implement Resource API Conext to log through an IO object, defaulting to `$stderr`. +# There is no access to a device here. You can supply a transport if necessary. class Puppet::ResourceApi::IOContext < Puppet::ResourceApi::BaseContext - def initialize(definition, target = $stderr) + attr_reader :transport + + def initialize(definition, target = $stderr, transport = nil) super(definition) @target = target + @transport = transport end protected diff --git a/lib/puppet/resource_api/puppet_context.rb b/lib/puppet/resource_api/puppet_context.rb index 770c7692..be057bcc 100644 --- a/lib/puppet/resource_api/puppet_context.rb +++ b/lib/puppet/resource_api/puppet_context.rb @@ -1,6 +1,8 @@ require 'puppet/resource_api/base_context' require 'puppet/util/logging' +# Implement Resource API Context to log through Puppet facilities +# and access/expose the puppet process' current device/transport class Puppet::ResourceApi::PuppetContext < Puppet::ResourceApi::BaseContext def device # TODO: evaluate facter_url setting for loading config if there is no `current` NetworkDevice @@ -8,6 +10,10 @@ def device Puppet::Util::NetworkDevice.current end + def transport + device.transport + end + def log_exception(exception, message: 'Error encountered', trace: false) super(exception, message: message, trace: trace || Puppet[:trace]) end diff --git a/lib/puppet/resource_api/transport.rb b/lib/puppet/resource_api/transport.rb new file mode 100644 index 00000000..438333f1 --- /dev/null +++ b/lib/puppet/resource_api/transport.rb @@ -0,0 +1,95 @@ +module Puppet::ResourceApi; end # rubocop:disable Style/Documentation + +# Remote target transport API +module Puppet::ResourceApi::Transport + def register(schema) + raise Puppet::DevError, 'requires a hash as schema, not `%{other_type}`' % { other_type: schema.class } unless schema.is_a? Hash + raise Puppet::DevError, 'requires a `:name`' unless schema.key? :name + raise Puppet::DevError, 'requires `:desc`' unless schema.key? :desc + raise Puppet::DevError, 'requires `:connection_info`' unless schema.key? :connection_info + raise Puppet::DevError, '`:connection_info` must be a hash, not `%{other_type}`' % { other_type: schema[:connection_info].class } unless schema[:connection_info].is_a?(Hash) + + init_transports + unless @transports[@environment][schema[:name]].nil? + raise Puppet::DevError, 'Transport `%{name}` is already registered for `%{environment}`' % { + name: schema[:name], + environment: @environment, + } + end + @transports[@environment][schema[:name]] = Puppet::ResourceApi::TransportSchemaDef.new(schema) + end + module_function :register # rubocop:disable Style/AccessModifierDeclarations + + # retrieve a Hash of transport schemas, keyed by their name. + def list + init_transports + Marshal.load(Marshal.dump(@transports[@environment])) + end + module_function :list # rubocop:disable Style/AccessModifierDeclarations + + def connect(name, connection_info) + validate(name, connection_info) + require "puppet/transport/#{name}" + class_name = name.split('_').map { |e| e.capitalize }.join + Puppet::Transport.const_get(class_name).new(get_context(name), wrap_sensitive(name, connection_info)) + end + module_function :connect # rubocop:disable Style/AccessModifierDeclarations + + def inject_device(name, transport) + transport_wrapper = Puppet::ResourceApi::Transport::Wrapper.new(name, transport) + + if Puppet::Util::NetworkDevice.respond_to?(:set_device) + Puppet::Util::NetworkDevice.set_device(name, transport_wrapper) + else + Puppet::Util::NetworkDevice.instance_variable_set(:@current, transport_wrapper) + end + end + module_function :inject_device # rubocop:disable Style/AccessModifierDeclarations + + def self.validate(name, connection_info) + init_transports + require "puppet/transport/schema/#{name}" unless @transports[@environment].key? name + transport_schema = @transports[@environment][name] + if transport_schema.nil? + raise Puppet::DevError, 'Transport for `%{target}` not registered with `%{environment}`' % { + target: name, + environment: @environment, + } + end + message_prefix = 'The connection info provided does not match the Transport Schema' + transport_schema.check_schema(connection_info, message_prefix) + transport_schema.validate(connection_info) + end + private_class_method :validate + + def self.get_context(name) + require 'puppet/resource_api/puppet_context' + Puppet::ResourceApi::PuppetContext.new(@transports[@environment][name]) + end + private_class_method :get_context + + def self.init_transports + lookup = Puppet.lookup(:current_environment) if Puppet.respond_to? :lookup + @environment = if lookup.nil? + :transports_default + else + lookup.name + end + @transports ||= {} + @transports[@environment] ||= {} + end + private_class_method :init_transports + + def self.wrap_sensitive(name, connection_info) + transport_schema = @transports[@environment][name] + if transport_schema + transport_schema.definition[:connection_info].each do |attr_name, options| + if options.key?(:sensitive) && (options[:sensitive] == true) + connection_info[attr_name] = Puppet::Pops::Types::PSensitiveType::Sensitive.new(connection_info[attr_name]) + end + end + end + connection_info + end + private_class_method :wrap_sensitive +end diff --git a/lib/puppet/resource_api/transport/wrapper.rb b/lib/puppet/resource_api/transport/wrapper.rb new file mode 100644 index 00000000..fe4a41e2 --- /dev/null +++ b/lib/puppet/resource_api/transport/wrapper.rb @@ -0,0 +1,55 @@ +require 'puppet/resource_api/transport' +require 'hocon' +require 'hocon/config_syntax' + +# Puppet::ResourceApi::Transport::Wrapper` to interface between the Util::NetworkDevice +class Puppet::ResourceApi::Transport::Wrapper + attr_reader :transport, :schema + + def initialize(name, url_or_config_or_transport) + if url_or_config_or_transport.is_a? String + url = URI.parse(url_or_config_or_transport) + raise "Unexpected url '#{url_or_config_or_transport}' found. Only file:/// URLs for configuration supported at the moment." unless url.scheme == 'file' + raise "Trying to load config from '#{url.path}, but file does not exist." if url && !File.exist?(url.path) + config = self.class.deep_symbolize(Hocon.load(url.path, syntax: Hocon::ConfigSyntax::HOCON) || {}) + elsif url_or_config_or_transport.is_a? Hash + config = url_or_config_or_transport + elsif transport_class?(name, url_or_config_or_transport) + @transport = url_or_config_or_transport + end + + @transport ||= Puppet::ResourceApi::Transport.connect(name, config) + @schema = Puppet::ResourceApi::Transport.list[name] + end + + def transport_class?(name, transport) + class_name = name.split('_').map { |e| e.capitalize }.join + expected = Puppet::Transport.const_get(class_name).to_s + expected == transport.class.to_s + end + + def facts + context = Puppet::ResourceApi::PuppetContext.new(@schema) + # @transport.facts + custom_facts # look into custom facts work by TP + @transport.facts(context) + end + + def respond_to_missing?(name, _include_private) + (@transport.respond_to? name) || super + end + + def method_missing(method_name, *args, &block) + if @transport.respond_to? method_name + @transport.send(method_name, *args, &block) + else + super + end + end + + # From https://stackoverflow.com/a/11788082/4918 + def self.deep_symbolize(obj) + return obj.each_with_object({}) { |(k, v), memo| memo[k.to_sym] = deep_symbolize(v); } if obj.is_a? Hash + return obj.each_with_object([]) { |v, memo| memo << deep_symbolize(v); } if obj.is_a? Array + obj + end +end diff --git a/lib/puppet/resource_api/type_definition.rb b/lib/puppet/resource_api/type_definition.rb index e23a077a..40bd3713 100644 --- a/lib/puppet/resource_api/type_definition.rb +++ b/lib/puppet/resource_api/type_definition.rb @@ -1,144 +1,187 @@ # Provides accessor methods for the type being provided -class Puppet::ResourceApi::TypeDefinition - attr_reader :definition - - def initialize(definition) - @data_type_cache = {} - validate_schema(definition) - end +module Puppet::ResourceApi + # pre-declare class + class BaseTypeDefinition; end + + # RSAPI Resource Type + class TypeDefinition < BaseTypeDefinition + def initialize(definition) + super(definition, :attributes) + end - def name - @definition[:name] - end + def ensurable? + attributes.key?(:ensure) + end - def attributes - @definition[:attributes] - end + # rubocop complains when this is named has_feature? + def feature?(feature) + supported = (definition[:features] && definition[:features].include?(feature)) + if supported + Puppet.debug("#{definition[:name]} supports `#{feature}`") + else + Puppet.debug("#{definition[:name]} does not support `#{feature}`") + end + supported + end - def ensurable? - @definition[:attributes].key?(:ensure) - end + def validate_schema(definition, attr_key) + super(definition, attr_key) + [:title, :provider, :alias, :audit, :before, :consume, :export, :loglevel, :noop, :notify, :require, :schedule, :stage, :subscribe, :tag].each do |name| + raise Puppet::DevError, 'must not define an attribute called `%{name}`' % { name: name.inspect } if definition[attr_key].key? name + end + if definition.key?(:title_patterns) && !definition[:title_patterns].is_a?(Array) + raise Puppet::DevError, '`:title_patterns` must be an array, not `%{other_type}`' % { other_type: definition[:title_patterns].class } + end - def namevars - @namevars ||= @definition[:attributes].select { |_name, options| - options.key?(:behaviour) && options[:behaviour] == :namevar - }.keys - end + Puppet::ResourceApi::DataTypeHandling.validate_ensure(definition) - # rubocop complains when this is named has_feature? - def feature?(feature) - supported = (definition[:features] && definition[:features].include?(feature)) - if supported - Puppet.debug("#{definition[:name]} supports `#{feature}`") - else - Puppet.debug("#{definition[:name]} does not support `#{feature}`") + definition[:features] ||= [] + supported_features = %w[supports_noop canonicalize remote_resource simple_get_filter].freeze + unknown_features = definition[:features] - supported_features + Puppet.warning("Unknown feature detected: #{unknown_features.inspect}") unless unknown_features.empty? end - supported end - def validate_schema(definition) - raise Puppet::DevError, 'Type definition must be a Hash, not `%{other_type}`' % { other_type: definition.class } unless definition.is_a?(Hash) - raise Puppet::DevError, 'Type definition must have a name' unless definition.key? :name - raise Puppet::DevError, 'Type definition must have `:attributes`' unless definition.key? :attributes - unless definition[:attributes].is_a?(Hash) - raise Puppet::DevError, '`%{name}.attributes` must be a hash, not `%{other_type}`' % { - name: definition[:name], other_type: definition[:attributes].class - } - end - [:title, :provider, :alias, :audit, :before, :consume, :export, :loglevel, :noop, :notify, :require, :schedule, :stage, :subscribe, :tag].each do |name| - raise Puppet::DevError, 'must not define an attribute called `%{name}`' % { name: name.inspect } if definition[:attributes].key? name + # RSAPI Transport schema + class TransportSchemaDef < BaseTypeDefinition + def initialize(definition) + super(definition, :connection_info) end - if definition.key?(:title_patterns) && !definition[:title_patterns].is_a?(Array) - raise Puppet::DevError, '`:title_patterns` must be an array, not `%{other_type}`' % { other_type: definition[:title_patterns].class } + + def validate(resource) + # enforce mandatory attributes + missing_attrs = [] + + attributes.each do |name, _options| + type = @data_type_cache[attributes[name][:type]] + + if resource[name].nil? && !(type.instance_of? Puppet::Pops::Types::POptionalType) + missing_attrs << name + end + end + + error_msg = "The following mandatory attributes were not provided:\n * " + missing_attrs.join(", \n * ") + raise Puppet::ResourceError, error_msg if missing_attrs.any? end + end - Puppet::ResourceApi::DataTypeHandling.validate_ensure(definition) + # Base RSAPI schema Object + class BaseTypeDefinition + attr_reader :definition, :attributes - definition[:features] ||= [] - supported_features = %w[supports_noop canonicalize remote_resource simple_get_filter].freeze - unknown_features = definition[:features] - supported_features - Puppet.warning("Unknown feature detected: #{unknown_features.inspect}") unless unknown_features.empty? + def initialize(definition, attr_key) + @data_type_cache = {} + validate_schema(definition, attr_key) + # store the validated definition + @definition = definition + end - definition[:attributes].each do |key, attr| - raise Puppet::DevError, "`#{definition[:name]}.#{key}` must be a Hash, not a #{attr.class}" unless attr.is_a? Hash - raise Puppet::DevError, "`#{definition[:name]}.#{key}` has no type" unless attr.key? :type - Puppet.warning("`#{definition[:name]}.#{key}` has no docs") unless attr.key? :desc + def name + definition[:name] + end - # validate the type by attempting to parse into a puppet type - @data_type_cache[definition[:attributes][key][:type]] ||= - Puppet::ResourceApi::DataTypeHandling.parse_puppet_type( - key, - definition[:attributes][key][:type], - ) + def namevars + @namevars ||= attributes.select { |_name, options| + options.key?(:behaviour) && options[:behaviour] == :namevar + }.keys + end - # fixup any weird behavior ;-) - next unless attr[:behavior] - if attr[:behaviour] - raise Puppet::DevError, "the '#{key}' attribute has both a `behavior` and a `behaviour`, only use one" + def validate_schema(definition, attr_key) + raise Puppet::DevError, '%{type_class} must be a Hash, not `%{other_type}`' % { type_class: self.class.name, other_type: definition.class } unless definition.is_a?(Hash) + @attributes = definition[attr_key] + raise Puppet::DevError, '%{type_class} must have a name' % { type_class: self.class.name } unless definition.key? :name + raise Puppet::DevError, '%{type_class} must have `%{attr_key}`' % { type_class: self.class.name, attrs: attr_key } unless definition.key? attr_key + unless attributes.is_a?(Hash) + raise Puppet::DevError, '`%{name}.%{attrs}` must be a hash, not `%{other_type}`' % { + name: definition[:name], attrs: attr_key, other_type: attributes.class + } end - attr[:behaviour] = attr[:behavior] - attr.delete(:behavior) - end - # store the validated definition - @definition = definition - end - # validates a resource hash against its type schema - def check_schema(resource) - namevars.each do |namevar| - if resource[namevar].nil? - raise Puppet::ResourceError, "`#{name}.get` did not return a value for the `#{namevar}` namevar attribute" + attributes.each do |key, attr| + raise Puppet::DevError, "`#{definition[:name]}.#{key}` must be a Hash, not a #{attr.class}" unless attr.is_a? Hash + raise Puppet::DevError, "`#{definition[:name]}.#{key}` has no type" unless attr.key? :type + Puppet.warning("`#{definition[:name]}.#{key}` has no docs") unless attr.key? :desc + + # validate the type by attempting to parse into a puppet type + @data_type_cache[attributes[key][:type]] ||= + Puppet::ResourceApi::DataTypeHandling.parse_puppet_type( + key, + attributes[key][:type], + ) + + # fixup any weird behavior ;-) + next unless attr[:behavior] + if attr[:behaviour] + raise Puppet::DevError, "the '#{key}' attribute has both a `behavior` and a `behaviour`, only use one" + end + attr[:behaviour] = attr[:behavior] + attr.delete(:behavior) end end - message = "Provider returned data that does not match the Type Schema for `#{name}[#{resource[namevars.first]}]`" + # validates a resource hash against its type schema + def check_schema(resource, message_prefix = nil) + namevars.each do |namevar| + if resource[namevar].nil? + raise Puppet::ResourceError, "`#{name}.get` did not return a value for the `#{namevar}` namevar attribute" + end + end - rejected_keys = check_schema_keys(resource) # removes bad keys - bad_values = check_schema_values(resource) + message_prefix = 'Provider returned data that does not match the Type Schema' if message_prefix.nil? + message = "#{message_prefix} for `#{name}[#{resource[namevars.first]}]`" - unless rejected_keys.empty? - message += "\n Unknown attribute:\n" - rejected_keys.each { |key, _value| message += " * #{key}\n" } - end - unless bad_values.empty? - message += "\n Value type mismatch:\n" - bad_values.each { |key, value| message += " * #{key}: #{value}\n" } - end + rejected_keys = check_schema_keys(resource) + bad_values = check_schema_values(resource) - return if rejected_keys.empty? && bad_values.empty? + unless rejected_keys.empty? + message += "\n Unknown attribute:\n" + rejected_keys.each { |key, _value| message += " * #{key}\n" } + end + unless bad_values.empty? + message += "\n Value type mismatch:\n" + bad_values.each { |key, value| message += " * #{key}: #{value}\n" } + end + + return if rejected_keys.empty? && bad_values.empty? - if Puppet.settings[:strict] == :off - Puppet.debug(message) - elsif Puppet.settings[:strict] == :warning - Puppet::ResourceApi.warning_count += 1 - Puppet.warning(message) if Puppet::ResourceApi.warning_count <= 100 # maximum number of schema warnings to display in a run - elsif Puppet.settings[:strict] == :error - raise Puppet::DevError, message + if Puppet.settings[:strict] == :off + Puppet.debug(message) + elsif Puppet.settings[:strict] == :warning + Puppet::ResourceApi.warning_count += 1 + Puppet.warning(message) if Puppet::ResourceApi.warning_count <= 100 # maximum number of schema warnings to display in a run + elsif Puppet.settings[:strict] == :error + raise Puppet::DevError, message + end end - end - # Returns an array of keys that where not found in the type schema - # Modifies the resource passed in, leaving only valid attributes - def check_schema_keys(resource) - rejected = [] - resource.reject! { |key| rejected << key if key != :title && attributes.key?(key) == false } - rejected - end + # Returns an array of keys that where not found in the type schema + # No longer modifies the resource passed in + def check_schema_keys(resource) + rejected = [] + resource.reject { |key| rejected << key if key != :title && attributes.key?(key) == false } + rejected + end - # Returns a hash of keys and values that are not valid - # does not modify the resource passed in - def check_schema_values(resource) - bad_vals = {} - resource.each do |key, value| - next unless attributes[key] - type = @data_type_cache[attributes[key][:type]] - error_message = Puppet::ResourceApi::DataTypeHandling.try_validate( - type, - value, - '', - ) - bad_vals[key] = value unless error_message.nil? + # Returns a hash of keys and values that are not valid + # does not modify the resource passed in + def check_schema_values(resource) + bad_vals = {} + resource.each do |key, value| + next unless attributes[key] + type = @data_type_cache[attributes[key][:type]] + is_sensitive = (attributes[key].key?(:sensitive) && (attributes[key][:sensitive] == true)) + error_message = Puppet::ResourceApi::DataTypeHandling.try_validate( + type, + value, + '', + ) + if is_sensitive + bad_vals[key] = '<< redacted value >> ' + error_message unless error_message.nil? + else + bad_vals[key] = value unless error_message.nil? + end + end + bad_vals end - bad_vals end end diff --git a/spec/acceptance/device_spec.rb b/spec/acceptance/device_spec.rb index 69302007..788244dd 100644 --- a/spec/acceptance/device_spec.rb +++ b/spec/acceptance/device_spec.rb @@ -74,9 +74,18 @@ <= Gem::Version.new('5.3.6') && Gem::Version.new(Puppet::PUPPETVERSION) != Gem::Version.new('5.4.0') @@ -86,10 +95,14 @@ def is_device_apply_supported? 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 + + device_credentials.write(device_credentials_content) + device_credentials.close end after(:each) do device_conf.unlink + device_credentials.unlink end context 'with no config specified' do diff --git a/spec/acceptance/sensitive_spec.rb b/spec/acceptance/sensitive_spec.rb index f48341fb..e23c4088 100644 --- a/spec/acceptance/sensitive_spec.rb +++ b/spec/acceptance/sensitive_spec.rb @@ -21,6 +21,22 @@ expect(stdout_str).not_to match %r{foo} expect(stdout_str).not_to match %r{warn|error}i end + + context 'when a sensitive value is not the top level type' do + it 'is not exposed by a provider' do + stdout_str, _status = Open3.capture2e("puppet apply #{common_args} -e \"test_sensitive { bar: secret => Sensitive('foo'), "\ + "optional_secret => Sensitive('optional foo'), variant_secret => [Sensitive('variant foo')] }\"") + expect(stdout_str).to match %r{redacted} + expect(stdout_str).not_to match %r{variant foo} + expect(stdout_str).not_to match %r{warn|error}i + end + it 'properly validates the sensitive type value' do + stdout_str, _status = Open3.capture2e("puppet apply #{common_args} -e \"test_sensitive { bar: secret => Sensitive('foo'), "\ + "optional_secret => Sensitive('optional foo'), variant_secret => [Sensitive(134679)] }\"") + expect(stdout_str).to match %r{Sensitive\[String\]( value)?, got Sensitive\[Integer\]} + expect(stdout_str).not_to match %r{134679} + end + end end describe 'using `puppet resource`' do diff --git a/spec/acceptance/transport/transport_spec.rb b/spec/acceptance/transport/transport_spec.rb new file mode 100644 index 00000000..3fe7c0b5 --- /dev/null +++ b/spec/acceptance/transport/transport_spec.rb @@ -0,0 +1,185 @@ +require 'spec_helper' +require 'tempfile' +require 'open3' + +RSpec.describe 'a transport' do + let(:common_args) { '--verbose --trace --debug --strict=error --modulepath spec/fixtures' } + + before(:all) do + FileUtils.mkdir_p(File.expand_path('~/.puppetlabs/opt/puppet/cache/devices/the_node/state')) + end + + describe 'using `puppet device`' do + let(:common_args) { super() + ' --target the_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 + + device_credentials.write(device_credentials_content) + device_credentials.close + end + + after(:each) do + device_conf.unlink + device_credentials.unlink + end + + context 'when all sensitive values are valid' do + let(:device_credentials_content) do + <"foo"} + expect(stdout_str).not_to match %r{wibble} + expect(stdout_str).not_to match %r{bar} + expect(stdout_str).not_to match %r{meep} + expect(stdout_str).not_to match %r{1234567890} + end + end + end + + context 'with a sensitive string value that is invalid' do + let(:device_credentials_content) do + <> } + expect(stdout_str).not_to match %r{optional_secret} + expect(stdout_str).not_to match %r{array_secret} + expect(stdout_str).not_to match %r{variant_secret} + end + end + end + + context 'with an optional sensitive string value that is invalid' do + let(:device_credentials_content) do + <>} + expect(stdout_str).not_to match %r{array_secret} + expect(stdout_str).not_to match %r{variant_secret} + end + end + end + + context 'with an array of sensitive strings that is invalid' do + let(:device_credentials_content) do + <>} + expect(stdout_str).not_to match %r{variant_secret} + end + end + end + + context 'with an variant containing a sensitive value that is invalid' do + let(:device_credentials_content) do + <>} + end + end + end + end +end diff --git a/spec/fixtures/test_module/lib/puppet/transport/schema/test_device.rb b/spec/fixtures/test_module/lib/puppet/transport/schema/test_device.rb new file mode 100644 index 00000000..df2f7a9b --- /dev/null +++ b/spec/fixtures/test_module/lib/puppet/transport/schema/test_device.rb @@ -0,0 +1,32 @@ +require 'puppet/resource_api' + +Puppet::ResourceApi.register_transport( + name: 'test_device', # points at class Puppet::Transport::TestDevice + desc: 'Connects to a device', + connection_info: { + username: { + type: 'String', + desc: 'The name of the resource you want to manage.', + }, + secret: { + type: 'String', + desc: 'A secret to protect.', + sensitive: true, + }, + optional_secret: { + type: 'Optional[String]', + desc: 'An optional secret to protect.', + sensitive: true, + }, + array_secret: { + type: 'Optional[Array[String]]', + desc: 'An array secret to protect.', + sensitive: true, + }, + variant_secret: { + type: 'Optional[Variant[Array[String], Integer]]', + desc: 'An array secret to protect.', + sensitive: true, + }, + }, +) diff --git a/spec/fixtures/test_module/lib/puppet/transport/schema/test_device_sensitive.rb b/spec/fixtures/test_module/lib/puppet/transport/schema/test_device_sensitive.rb new file mode 100644 index 00000000..a22c43aa --- /dev/null +++ b/spec/fixtures/test_module/lib/puppet/transport/schema/test_device_sensitive.rb @@ -0,0 +1,32 @@ +require 'puppet/resource_api' + +Puppet::ResourceApi.register_transport( + name: 'test_device_sensitive', # points at class Puppet::Transport::TestDevice + desc: 'Connects to a device', + connection_info: { + username: { + type: 'String', + desc: 'The name of the resource you want to manage.', + }, + secret_string: { + type: 'String', + desc: 'A secret to protect.', + sensitive: true, + }, + optional_secret: { + type: 'Optional[String]', + desc: 'An optional secret to protect.', + sensitive: true, + }, + array_secret: { + type: 'Optional[Array[String]]', + desc: 'An array secret to protect.', + sensitive: true, + }, + variant_secret: { + type: 'Optional[Variant[Array[String], Integer]]', + desc: 'An array secret to protect.', + sensitive: true, + }, + }, +) diff --git a/spec/fixtures/test_module/lib/puppet/transport/test_device.rb b/spec/fixtures/test_module/lib/puppet/transport/test_device.rb new file mode 100644 index 00000000..7974f1d4 --- /dev/null +++ b/spec/fixtures/test_module/lib/puppet/transport/test_device.rb @@ -0,0 +1,21 @@ +module Puppet::Transport +# a transport for a test_device +class TestDevice + def initialize(_context, connection_info); + puts connection_info + end + + def facts(_context) + { 'foo' => 'bar' } + end + + def verify(_context) + return true + end + + def close(_context) + return + end +end + +end diff --git a/spec/fixtures/test_module/lib/puppet/transport/test_device_sensitive.rb b/spec/fixtures/test_module/lib/puppet/transport/test_device_sensitive.rb new file mode 100644 index 00000000..c2a898a2 --- /dev/null +++ b/spec/fixtures/test_module/lib/puppet/transport/test_device_sensitive.rb @@ -0,0 +1,21 @@ +module Puppet::Transport +# a transport for a test_device +class TestDeviceSensitive + def initialize(_context, connection_info); + puts "transport connection_info: #{connection_info}" + end + + def facts(_context) + { 'foo' => 'bar' } + end + + def verify(_context) + return true + end + + def close(_context) + return + end +end + +end diff --git a/spec/fixtures/test_module/lib/puppet/type/test_sensitive.rb b/spec/fixtures/test_module/lib/puppet/type/test_sensitive.rb index ce74363d..f42f89c9 100644 --- a/spec/fixtures/test_module/lib/puppet/type/test_sensitive.rb +++ b/spec/fixtures/test_module/lib/puppet/type/test_sensitive.rb @@ -26,7 +26,11 @@ desc: 'An optional secret to protect.', }, array_secret: { - type: 'Array[Sensitive[String]]', + type: 'Optional[Array[Sensitive[String]]]', + desc: 'An array secret to protect.', + }, + variant_secret: { + type: 'Optional[Variant[Array[Sensitive[String]], Integer]]', desc: 'An array secret to protect.', }, }, diff --git a/spec/fixtures/test_module/lib/puppet/util/network_device/test_device/device.rb b/spec/fixtures/test_module/lib/puppet/util/network_device/test_device/device.rb index 7fb6ebd6..505209a5 100644 --- a/spec/fixtures/test_module/lib/puppet/util/network_device/test_device/device.rb +++ b/spec/fixtures/test_module/lib/puppet/util/network_device/test_device/device.rb @@ -1,10 +1,10 @@ -require 'puppet/util/network_device/simple/device' +require 'puppet/resource_api/transport/wrapper' module Puppet::Util::NetworkDevice::Test_device # rubocop:disable Style/ClassAndModuleCamelCase - # A simple test device returning hardcoded facts - class Device < Puppet::Util::NetworkDevice::Simple::Device - def facts - { 'foo' => 'bar' } + class Device < Puppet::ResourceApi::Transport::Wrapper + def initialize(url_or_config, _options = {}) + puts url_or_config.inspect + super('test_device', url_or_config) end end end diff --git a/spec/fixtures/test_module/lib/puppet/util/network_device/test_device_sensitive/device.rb b/spec/fixtures/test_module/lib/puppet/util/network_device/test_device_sensitive/device.rb new file mode 100644 index 00000000..4c04b7cf --- /dev/null +++ b/spec/fixtures/test_module/lib/puppet/util/network_device/test_device_sensitive/device.rb @@ -0,0 +1,12 @@ +require 'puppet/resource_api/transport/wrapper' + +class Puppet::Util::NetworkDevice; end + +module Puppet::Util::NetworkDevice::Test_device_sensitive # rubocop:disable Style/ClassAndModuleCamelCase + # The main class for handling the connection and command parsing to the IOS Catalyst device + class Device < Puppet::ResourceApi::Transport::Wrapper + def initialize(url_or_config, _options = {}) + super('test_device_sensitive', url_or_config) + end + end +end \ No newline at end of file diff --git a/spec/puppet/resource_api/base_context_spec.rb b/spec/puppet/resource_api/base_context_spec.rb index 34869f75..9569faf3 100644 --- a/spec/puppet/resource_api/base_context_spec.rb +++ b/spec/puppet/resource_api/base_context_spec.rb @@ -13,10 +13,17 @@ def send_log(log, msg) TestContext.new(definition) end - let(:definition) { { name: 'some_resource', attributes: { name: { type: 'String', desc: 'message' } }, features: feature_support } } + let(:definition_hash) { { name: 'some_resource', attributes: { name: { type: 'String', desc: 'message' } }, features: feature_support } } + let(:definition) { Puppet::ResourceApi::TypeDefinition.new(definition_hash) } let(:feature_support) { [] } - it { expect { described_class.new(nil) }.to raise_error ArgumentError, %r{BaseContext requires definition to be a Hash} } + it { expect { described_class.new(nil) }.to raise_error ArgumentError, %r{BaseContext requires definition to be a child of Puppet::ResourceApi::BaseTypeDefinition} } + describe 'legacy hash definition support' do + let(:definition) { definition_hash } + + it { expect { context }.not_to raise_error } + it { expect(context.type.name).to eq 'some_resource' } + end describe '#failed?' do it('defaults to false') { is_expected.not_to be_failed } @@ -334,6 +341,10 @@ def send_log(log, msg) it { expect { described_class.new(definition).device }.to raise_error RuntimeError, %r{Received device\(\) on an unprepared BaseContext\. Use a PuppetContext instead} } end + describe '#transport' do + it { expect { described_class.new(definition).transport }.to raise_error RuntimeError, %r{No transport available\.} } + end + describe '#send_log' do it { expect { described_class.new(definition).send_log(nil, nil) }.to raise_error RuntimeError, %r{Received send_log\(\) on an unprepared BaseContext\. Use IOContext, or PuppetContext instead} } end diff --git a/spec/puppet/resource_api/base_type_definition_spec.rb b/spec/puppet/resource_api/base_type_definition_spec.rb new file mode 100644 index 00000000..62a918fd --- /dev/null +++ b/spec/puppet/resource_api/base_type_definition_spec.rb @@ -0,0 +1,265 @@ +require 'spec_helper' + +RSpec.describe Puppet::ResourceApi::BaseTypeDefinition do + subject(:type) { described_class.new(definition, :attributes) } + + let(:definition) do + { name: 'some_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, + }, + prop: { + type: 'Integer', + desc: 'A mandatory property, that MUST NOT be validated on deleting.', + }, + }, features: feature_support } + end + let(:feature_support) { [] } + + it { expect { described_class.new(nil, :attributes) }.to raise_error Puppet::DevError, %r{BaseTypeDefinition must be a Hash} } + + describe '.name' do + it { expect(type.name).to eq 'some_resource' } + end + + describe '#check_schema_keys' do + context 'when resource contains only valid keys' do + it 'returns an empty array' do + expect(type.check_schema_keys(definition[:attributes])).to eq([]) + end + end + + context 'when resource contains invalid keys' do + let(:resource) { { name: 'test_string', wibble: '1', foo: '2' } } + let(:resource_copy) { { name: 'test_string', wibble: '1', foo: '2' } } + + it 'returns an array containing the bad keys' do + expect(type.check_schema_keys(resource)).to eq([:wibble, :foo]) + end + + it 'does not modify the resource passed in' do + type.check_schema_keys(resource) + expect(resource).to eq(resource_copy) + end + end + end + + describe '#check_schema_values' do + context 'when the definition is a type' do + context 'when resource contains only valid values' do + let(:resource) { { name: 'some_resource', prop: 1, ensure: 'present' } } + + it 'returns an empty array' do + expect(type.check_schema_values(resource)).to eq({}) + end + end + + context 'when resource contains invalid values' do + let(:resource) { { name: 'test_string', prop: 'foo', ensure: 1 } } + + it 'returns a hash of the keys that have invalid values' do + expect(type.check_schema_values(resource)).to eq(prop: 'foo', ensure: 1) + end + end + end + + context 'when the definition is a transport' do + subject(:type) { described_class.new(definition, :connection_info) } + + let(:definition) do + { + name: 'some_transport', + connection_info: { + username: { + type: 'String', + desc: 'The username to connect with', + }, + secret: { + type: 'String', + desc: 'A sensitive value', + sensitive: true, + }, + }, + } + end + + context 'when resource contains only valid values' do + let(:resource) { { username: 'wibble', secret: 'foo' } } + + it 'returns an empty array' do + expect(type.check_schema_values(resource)).to eq({}) + end + end + + context 'when resource contains invalid values' do + let(:resource) { { username: 'wibble', secret: 12_345 } } + + it 'returns a hash of the keys that have invalid values' do + expect(type.check_schema_values(resource)).to match(secret: %r{<< redacted value >> expect(s|ed) a String value, got Integer}) + end + end + end + end + + describe '#check_schema' do + context 'when resource does not contain its namevar' do + let(:resource) { { nom: 'some_resource', prop: 1, ensure: 'present' } } + + it { expect { type.check_schema(resource) }.to raise_error Puppet::ResourceError, %r{`some_resource.get` did not return a value for the `name` namevar attribute} } + end + + context 'when a resource contains unknown attributes' do + let(:resource) { { name: 'wibble', prop: 1, ensure: 'present', foo: 'bar' } } + let(:message) { %r{Provider returned data that does not match the Type Schema for `some_resource\[wibble\]`\n\s*Unknown attribute:\n\s*\* foo} } + let(:strict_level) { :warning } + + before(:each) do + Puppet::ResourceApi.warning_count = 0 + Puppet.settings[:strict] = strict_level + end + + context 'when puppet strict is set to default (warning)' do + it 'displays up to 100 warnings' do + expect(Puppet).to receive(:warning).with(message).exactly(100).times + 110.times do + type.check_schema(resource) + end + end + end + + context 'when puppet strict is set to error' do + let(:strict_level) { :error } + + it 'raises a DevError' do + expect { type.check_schema(resource) }.to raise_error Puppet::DevError, message + end + end + + context 'when puppet strict is set to off' do + let(:strict_level) { :off } + + it 'logs to Debug console' do + expect(Puppet).to receive(:debug).with(message) + type.check_schema(resource) + end + end + end + + context 'when a resource contains invalid value' do + let(:resource) { { name: 'wibble', prop: 'foo', ensure: 'present' } } + let(:message) { %r{Provider returned data that does not match the Type Schema for `some_resource\[wibble\]`\n\s*Value type mismatch:\n\s*\* prop: foo} } + let(:strict_level) { :warning } + + before(:each) do + Puppet::ResourceApi.warning_count = 0 + Puppet.settings[:strict] = strict_level + end + + context 'when puppet strict is set to default (warning)' do + it 'displays up to 100 warnings' do + expect(Puppet).to receive(:warning).with(message).exactly(100).times + 110.times do + type.check_schema(resource) + end + end + end + + context 'when puppet strict is set to error' do + let(:strict_level) { :error } + + it 'raises a DevError' do + expect { type.check_schema(resource) }.to raise_error Puppet::DevError, message + end + end + + context 'when puppet strict is set to off' do + let(:strict_level) { :off } + + it 'logs to Debug console' do + expect(Puppet).to receive(:debug).with(message) + type.check_schema(resource) + end + end + end + end + + describe '#validate_schema' do + context 'when the type definition does not have a name' do + let(:definition) { { attributes: 'some_string' } } + + it { expect { type }.to raise_error Puppet::DevError, %r{must have a name} } + end + + context 'when attributes is not a hash' do + let(:definition) { { name: 'some_resource', attributes: 'some_string' } } + + it { expect { type }.to raise_error Puppet::DevError, %r{`some_resource.attributes` must be a hash} } + end + + context 'when an attribute is not a hash' do + let(:definition) { { name: 'some_resource', attributes: { name: 'some_string' } } } + + it { expect { type }.to raise_error Puppet::DevError, %r{`some_resource.name` must be a Hash} } + end + + context 'when an attribute has no type' do + let(:definition) { { name: 'some_resource', attributes: { name: { desc: 'message' } } } } + + it { expect { type }.to raise_error Puppet::DevError, %r{has no type} } + end + + context 'when an attribute has no descrption' do + let(:definition) { { name: 'some_resource', attributes: { name: { type: 'String' } } } } + + it 'Raises a warning message' do + expect(Puppet).to receive(:warning).with('`some_resource.name` has no docs') + type + end + end + + context 'when an attribute has an unsupported type' do + let(:definition) { { name: 'some_resource', attributes: { name: { type: 'basic' } } } } + + it { expect { type }.to raise_error %r{ is not a valid type specification} } + end + + context 'with both behavior and behaviour' do + let(:definition) do + { + name: 'bad_behaviour', + attributes: { + name: { + type: 'String', + behaviour: :namevar, + behavior: :namevar, + }, + }, + } + end + + it { expect { type }.to raise_error Puppet::DevError, %r{name.*attribute has both} } + end + + context 'when registering a type with badly formed attribute type' do + let(:definition) do + { + name: 'bad_syntax', + attributes: { + name: { + type: 'Optional[String', + }, + }, + } + end + + it { expect { type }.to raise_error Puppet::DevError, %r{The type of the `name` attribute `Optional\[String` could not be parsed:} } + end + end +end diff --git a/spec/puppet/resource_api/io_context_spec.rb b/spec/puppet/resource_api/io_context_spec.rb index 266095d0..a4cb8aa5 100644 --- a/spec/puppet/resource_api/io_context_spec.rb +++ b/spec/puppet/resource_api/io_context_spec.rb @@ -2,9 +2,10 @@ require 'puppet/resource_api/io_context' RSpec.describe Puppet::ResourceApi::IOContext do - subject(:context) { described_class.new(definition, io) } + subject(:context) { described_class.new(definition, io, transport) } let(:definition) { { name: 'some_resource', attributes: {} } } + let(:transport) { nil } let(:io) { StringIO.new('', 'w') } @@ -18,4 +19,13 @@ expect(io.string).to match %r{warning}i end end + + describe '#transport' do + it { expect(context.transport).to be_nil } + context 'when passing in a transport' do + let(:transport) { instance_double(Object, 'transport') } + + it { expect(context.transport).to eq transport } + end + end end diff --git a/spec/puppet/resource_api/puppet_context_spec.rb b/spec/puppet/resource_api/puppet_context_spec.rb index 96a3683e..dbc70c8b 100644 --- a/spec/puppet/resource_api/puppet_context_spec.rb +++ b/spec/puppet/resource_api/puppet_context_spec.rb @@ -18,7 +18,21 @@ end end - context 'with no NetworkDevice configured' do + context 'when a Transport::Wrapper device is configured' do + let(:device) { instance_double('Puppet::Util::NetworkDevice::Test_device::Device', 'device') } + let(:transport) { instance_double('Puppet::Transport::TestDevice', 'transport') } + + before(:each) do + allow(Puppet::Util::NetworkDevice).to receive(:current).and_return(device) + allow(device).to receive(:transport).and_return(transport) + end + + it 'returns the transport' do + expect(context.transport).to eq(transport) + end + end + + context 'with nothing configured' do before(:each) do allow(Puppet::Util::NetworkDevice).to receive(:current).and_return(nil) end diff --git a/spec/puppet/resource_api/transport/wrapper_spec.rb b/spec/puppet/resource_api/transport/wrapper_spec.rb new file mode 100644 index 00000000..1b56c20d --- /dev/null +++ b/spec/puppet/resource_api/transport/wrapper_spec.rb @@ -0,0 +1,135 @@ +require 'spec_helper' +require 'puppet/resource_api/transport/wrapper' +require_relative '../../../fixtures/test_module/lib/puppet/transport/test_device' + +RSpec.describe Puppet::ResourceApi::Transport::Wrapper, agent_test: true do + describe '#initialize(name, url_or_config)' do + context 'when called with a url' do + context 'with a file:// prefix' do + let(:url) { 'file:///etc/credentials' } + + it 'will not throw an error' do + allow(File).to receive(:exist?).and_return(true) + expect(Puppet::ResourceApi::Transport).to receive(:connect) + expect(Hocon).to receive(:load).with('/etc/credentials', any_args).and_return('foo' => %w[a b], 'bar' => 2) + expect { described_class.new('wibble', url) }.not_to raise_error + end + end + + context 'with an http:// prefix' do + let(:url) { 'http://www.puppet.com' } + + it { expect { described_class.new('wibble', url) }.to raise_error RuntimeError, %r{Only file:/// URLs for configuration supported} } + end + end + + context 'when called with a config hash' do + let(:config) { {} } + + it 'will use the configuration directly' do + expect(Hocon).not_to receive(:load) + expect(Puppet::ResourceApi::Transport).to receive(:connect) + described_class.new('wibble', config) + end + end + + before(:each) do + module Puppet::Transport + class SomethingSomethingDarkside; end + end + end + + context 'when called with a transport class' do + let(:transport) { Puppet::Transport::SomethingSomethingDarkside.new } + let(:instance) { described_class.new('something_something_darkside', transport) } + + it 'will set the @transport class variable' do + expect(instance.instance_variable_get(:@transport)).to eq(transport) + end + end + end + + describe '#facts' do + context 'when called' do + let(:instance) { described_class.new('wibble', {}) } + let(:context) { instance_double(Puppet::ResourceApi::PuppetContext, 'context') } + let(:facts) { { 'foo' => 'bar' } } + let(:transport) { instance_double(Puppet::Transport::TestDevice, 'transport') } + + it 'will return the facts provided by the transport' do + allow(Puppet::ResourceApi::Transport).to receive(:connect).and_return(transport) + allow(Puppet::ResourceApi::Transport).to receive(:list).and_return(schema: :dummy) + allow(Puppet::ResourceApi::PuppetContext).to receive(:new).and_return(context) + allow(transport).to receive(:facts).with(context).and_return(facts) + + expect(instance.facts).to eq(facts) + end + end + end + + context 'when an unsupported method is called' do + context 'when the transport can handle the method' do + let(:instance) { described_class.new('wibble', {}) } + let(:transport) { instance_double(Puppet::Transport::TestDevice, 'transport') } + let(:context) { instance_double(Puppet::ResourceApi::PuppetContext, 'context') } + + it 'will return the facts provided by the transport' do + allow(Puppet::ResourceApi::Transport).to receive(:connect).and_return(transport) + expect(transport).to receive(:close) + + instance.close(context) + end + end + + context 'when the transport cannot handle the method' do + let(:instance) { described_class.new('wibble', {}) } + let(:transport) { instance_double(Puppet::Transport::TestDevice, 'transport') } + + it 'will raise a NoMethodError' do + allow(Puppet::ResourceApi::Transport).to receive(:connect).and_return(transport) + expect { instance.wibble }.to raise_error NoMethodError + end + end + end + + context 'when a method is checked for' do + let(:instance) { described_class.new('wibble', {}) } + let(:transport) { instance_double(Puppet::Transport::TestDevice, 'transport') } + + before(:each) do + allow(Puppet::ResourceApi::Transport).to receive(:connect).and_return(transport) + end + + context 'when the transport does not support the function' do + context 'when using respond_to?' do + it 'will return false' do + expect(instance.respond_to?(:wibble)).to eq(false) + end + end + + context 'when using method?' do + it 'will return false' do + expect { instance.method :wibble }.to raise_error + end + end + end + + context 'when the transport does support the function' do + before(:each) do + allow(transport).to receive(:close) + end + + context 'when using respond_to?' do + it 'will return true' do + expect(instance.respond_to?(:close)).to eq(true) + end + end + + context 'when using method?' do + it 'will return the method' do + expect(instance.method(:close)).to be_a(Method) + end + end + end + end +end diff --git a/spec/puppet/resource_api/transport_schema_def_spec.rb b/spec/puppet/resource_api/transport_schema_def_spec.rb new file mode 100644 index 00000000..92cac1fa --- /dev/null +++ b/spec/puppet/resource_api/transport_schema_def_spec.rb @@ -0,0 +1,45 @@ +require 'spec_helper' + +RSpec.describe Puppet::ResourceApi::TransportSchemaDef do + subject(:type) { described_class.new(definition) } + + let(:definition) do + { name: 'some_target', + connection_info: { + host: { + type: 'String', + desc: 'The IP address or hostname', + }, + user: { + type: 'String', + desc: 'The user to connect as', + }, + } } + end + + it { expect { described_class.new(nil) }.to raise_error Puppet::DevError, %r{TransportSchemaDef must be a Hash} } + + describe '#attributes' do + context 'when type has attributes' do + it { expect(type.attributes).to be_key(:host) } + it { expect(type.attributes).to be_key(:user) } + end + end + + describe '#validate' do + context 'when resource is missing attributes' do + let(:resource) { {} } + + it 'raises an error listing the missing attributes' do + expect { type.validate(resource) }.to raise_error Puppet::ResourceError, %r{host} + expect { type.validate(resource) }.to raise_error Puppet::ResourceError, %r{user} + end + end + + context 'when resource has all its attributes' do + let(:resource) { { host: '1234', user: '4321' } } + + it { expect { type.validate(resource) }.not_to raise_error } + end + end +end diff --git a/spec/puppet/resource_api/transport_spec.rb b/spec/puppet/resource_api/transport_spec.rb new file mode 100644 index 00000000..ad59697d --- /dev/null +++ b/spec/puppet/resource_api/transport_spec.rb @@ -0,0 +1,371 @@ +require 'spec_helper' + +RSpec.describe Puppet::ResourceApi::Transport do + def change_environment(name = nil) + environment = class_double(Puppet::Node::Environment) + + if name.nil? + allow(Puppet).to receive(:respond_to?).and_return(false) + else + allow(Puppet).to receive(:respond_to?).and_return(true) + end + + allow(Puppet).to receive(:lookup).with(:current_environment).and_return(environment) + + # allow clean up scripts to run unhindered + allow(Puppet).to receive(:lookup).with(:root_environment).and_call_original + allow(Puppet).to receive(:lookup).with(:environments).and_call_original + + allow(environment).to receive(:name).and_return(name) + end + + let(:strict_level) { :error } + + before(:each) do + # set default to strictest setting + # by default Puppet runs at warning level + Puppet.settings[:strict] = strict_level + # Enable debug logging + Puppet.debug = true + end + + after(:each) do + # reset registered transports between tests to reduce cross-test poisoning + described_class.instance_variable_set(:@transports, nil) + end + + describe '#register(schema)' do + context 'when registering a schema with missing keys' do + it { expect { described_class.register([]) }.to raise_error(Puppet::DevError, %r{requires a hash as schema}) } + it { expect { described_class.register({}) }.to raise_error(Puppet::DevError, %r{requires a `:name`}) } + it { expect { described_class.register(name: 'no connection info', desc: 'some description') }.to raise_error(Puppet::DevError, %r{requires `:connection_info`}) } + it { expect { described_class.register(name: 'no description') }.to raise_error(Puppet::DevError, %r{requires `:desc`}) } + it { expect { described_class.register(name: 'no hash attributes', desc: 'some description', connection_info: []) }.to raise_error(Puppet::DevError, %r{`:connection_info` must be a hash, not}) } + end + + context 'when registering a minimal transport' do + let(:schema) { { name: 'minimal', desc: 'a minimal connection', connection_info: {} } } + + it { expect { described_class.register(schema) }.not_to raise_error } + + context 'when re-registering a transport' do + it { + described_class.register(schema) + expect { described_class.register(schema) }.to raise_error(Puppet::DevError, %r{`minimal` is already registered}) + } + end + end + + context 'when registering a transport' do + let(:schema) do + { + name: 'a_remote_thing', + desc: 'basic transport', + connection_info: { + host: { + type: 'String', + desc: 'the host ip address or hostname', + }, + user: { + type: 'String', + desc: 'the user to connect as', + }, + password: { + type: 'String', + sensitive: true, + desc: 'the password to make the connection', + }, + }, + } + end + let(:schema2) do + { + name: 'schema2', + desc: 'basic transport', + connection_info: { + host: { + type: 'String', + desc: 'the host ip address or hostname', + }, + }, + } + end + let(:schema3) do + { + name: 'schema3', + desc: 'basic transport', + connection_info: { + user: { + type: 'String', + desc: 'the user to connect as', + }, + password: { + type: 'String', + sensitive: true, + desc: 'the password to make the connection', + }, + }, + } + end + + it 'adds to the transports register' do + expect { described_class.register(schema) }.not_to raise_error + end + + context 'when a transports are added to multiple environments' do + it 'will record the schemas in the correct structure' do + change_environment(:wibble) + described_class.register(schema) + expect(described_class.instance_variable_get(:@transports)).to be_key(:wibble) + expect(described_class.instance_variable_get(:@transports)[:wibble][schema[:name]]).to be_a_kind_of(Puppet::ResourceApi::TransportSchemaDef) + expect(described_class.instance_variable_get(:@transports)[:wibble][schema[:name]].definition).to eq(schema) + + change_environment(:foo) + described_class.register(schema) + described_class.register(schema2) + expect(described_class.instance_variable_get(:@transports)).to be_key(:foo) + expect(described_class.instance_variable_get(:@transports)[:foo][schema[:name]]).to be_a_kind_of(Puppet::ResourceApi::TransportSchemaDef) + expect(described_class.instance_variable_get(:@transports)[:foo][schema[:name]].definition).to eq(schema) + expect(described_class.instance_variable_get(:@transports)[:foo][schema2[:name]]).to be_a_kind_of(Puppet::ResourceApi::TransportSchemaDef) + expect(described_class.instance_variable_get(:@transports)[:foo][schema2[:name]].definition).to eq(schema2) + + change_environment(:bar) + described_class.register(schema3) + expect(described_class.instance_variable_get(:@transports)).to be_key(:bar) + expect(described_class.instance_variable_get(:@transports)[:bar][schema3[:name]]).to be_a_kind_of(Puppet::ResourceApi::TransportSchemaDef) + expect(described_class.instance_variable_get(:@transports)[:bar][schema3[:name]].definition).to eq(schema3) + end + end + end + + context 'when registering a transport with a bad type' do + let(:schema) do + { + name: 'a_bad_thing', + desc: 'basic transport', + connection_info: { + host: { + type: 'garbage', + desc: 'the host ip address or hostname', + }, + }, + } + end + + it { + expect { described_class.register(schema) }.to raise_error( + Puppet::DevError, %r{ is not a valid type specification} + ) + } + end + end + + describe '#list' do + subject { described_class.list } + + context 'with no transports registered' do + it { is_expected.to eq({}) } + end + + context 'with a transport registered' do + let(:schema) do + { + name: 'test_target', + desc: 'a basic transport', + connection_info: { + host: { + type: 'String', + desc: 'the host ip address or hostname', + }, + }, + } + end + + before(:each) do + described_class.register(schema) + end + + it { expect(described_class.list['test_target'].definition).to eq schema } + it 'returns a new object' do + expect(described_class.list['test_target'].definition.object_id).not_to eq schema.object_id + end + end + end + + describe '#connect(name, connection_info)', agent_test: true do + let(:name) { 'test_target' } + let(:schema) do + { + name: 'test_target', + desc: 'a basic transport', + connection_info: { + host: { + type: 'String', + desc: 'the host ip address or hostname', + }, + }, + } + end + + context 'when the transport file does not exist' do + it 'throws a LoadError' do + expect(described_class).to receive(:validate).with(name, host: 'example.com') + expect { described_class.connect(name, host: 'example.com') }.to raise_error LoadError, %r{(no such file to load|cannot load such file) -- puppet/transport/test_target} + end + end + + context 'when the transport file does exist' do + context 'with an incorrectly defined transport' do + it 'throws a NameError' do + described_class.register(schema) + + expect(described_class).to receive(:validate).with(name, host: 'example.com') + expect(described_class).to receive(:require).with('puppet/transport/test_target') + expect { described_class.connect(name, host: 'example.com') }.to raise_error NameError, + %r{uninitialized constant (Puppet::Transport|TestTarget)} + end + end + + context 'with a correctly defined transport' do + let(:test_target) { double('Puppet::Transport::TestTarget') } # rubocop:disable RSpec/VerifiedDoubles + let(:context) { instance_double(Puppet::ResourceApi::PuppetContext, 'context') } + + it 'loads initiates the class successfully' do + described_class.register(schema) + + allow(described_class).to receive(:require).with('puppet/resource_api/puppet_context').and_call_original + expect(described_class).to receive(:require).with('puppet/transport/test_target') + expect(described_class).to receive(:validate).with(name, host: 'example.com') + expect(Puppet::ResourceApi::PuppetContext).to receive(:new).with(kind_of(Puppet::ResourceApi::TransportSchemaDef)).and_return(context) + + stub_const('Puppet::Transport::TestTarget', test_target) + expect(test_target).to receive(:new).with(context, host: 'example.com') + + described_class.connect(name, host: 'example.com') + end + end + end + end + + describe '#inject_device(name, transport)' do + let(:device_name) { 'wibble' } + let(:transport) { instance_double(Puppet::Transport::Wibble, 'transport') } + let(:wrapper) { instance_double(Puppet::ResourceApi::Transport::Wrapper, 'wrapper') } + + before(:each) do + module Puppet::Transport + class Wibble; end + end + end + + context 'when puppet has set_device' do + it 'wraps the transport and calls set_device within NetworkDevice' do + expect(Puppet::ResourceApi::Transport::Wrapper).to receive(:new).with(device_name, transport).and_return(wrapper) + allow(Puppet::Util::NetworkDevice).to receive(:respond_to?).with(:set_device).and_return(true) + expect(Puppet::Util::NetworkDevice).to receive(:set_device).with(device_name, wrapper) + + described_class.inject_device(device_name, transport) + end + end + + context 'when puppet does not have set_device' do + it 'wraps the transport and sets it as current in NetworkDevice' do + expect(Puppet::ResourceApi::Transport::Wrapper).to receive(:new).with(device_name, transport).and_return(wrapper) + expect(Puppet::Util::NetworkDevice).to receive(:respond_to?).with(:set_device).and_return(false) + + described_class.inject_device(device_name, transport) + + expect(Puppet::Util::NetworkDevice.current).to eq(wrapper) + end + end + end + + describe '#validate(name, connection_info)', agent_test: true do + context 'when the transport does not exist' do + it { expect { described_class.send(:validate, 'wibble', {}) }.to raise_error LoadError, %r{(no such file to load|cannot load such file) -- puppet/transport/schema/wibble} } + end + + context 'when the transport being validated has not be registered' do + it 'will throw an unregistered error message' do + expect(described_class).to receive(:require).with('puppet/transport/schema/wibble') + expect { described_class.send(:validate, 'wibble', {}) }.to raise_error Puppet::DevError, %r{ not registered with } + end + end + + context 'when the transport being validated has been registered' do + let(:schema) { { name: 'validate', desc: 'a minimal connection', connection_info: {} } } + let(:schema_def) { instance_double('Puppet::ResourceApi::TransportSchemaDef', 'schema_def') } + + it 'validates the connection_info' do + allow(Puppet::ResourceApi::TransportSchemaDef).to receive(:new).with(schema).and_return(schema_def) + + described_class.register(schema) + + expect(described_class).not_to receive(:require).with('puppet/transport/schema/validate') + expect(schema_def).to receive(:check_schema).with('connection_info', kind_of(String)).and_return(nil) + expect(schema_def).to receive(:validate).with('connection_info').and_return(nil) + + described_class.send :validate, 'validate', 'connection_info' + end + end + end + + describe '#init_transports' do + context 'when there is not a current_environment' do + it 'will return the default transport environment name' do + change_environment + + described_class.send :init_transports + + expect(described_class.instance_variable_get(:@environment)).to eq(:transports_default) + end + end + + context 'when there is a current_environment' do + it 'will return the set environment name' do + change_environment(:something) + + described_class.send :init_transports + + expect(described_class.instance_variable_get(:@environment)).to eq(:something) + end + end + end + + describe '#wrap_sensitive(name, connection_info)' do + context 'when the connection info contains a `Sensitive` type' do + let(:schema) do + { + name: 'sensitive_transport', + desc: 'a secret', + connection_info: { + secret: { + type: 'String', + desc: 'A secret to protect.', + sensitive: true, + }, + }, + } + end + let(:schema_def) { instance_double('Puppet::ResourceApi::TransportSchemaDef', 'schema_def') } + let(:connection_info) do + { + secret: 'sup3r_secret_str1ng', + } + end + + before(:each) do + allow(Puppet::ResourceApi::TransportSchemaDef).to receive(:new).with(schema).and_return(schema_def) + described_class.register(schema) + end + + it 'wraps the value in a PSensitiveType' do + allow(schema_def).to receive(:definition).and_return(schema) + + conn_info = described_class.send :wrap_sensitive, 'sensitive_transport', connection_info + expect(conn_info[:secret]).to be_a(Puppet::Pops::Types::PSensitiveType::Sensitive) + expect(conn_info[:secret].unwrap).to eq('sup3r_secret_str1ng') + end + end + end +end diff --git a/spec/puppet/resource_api/type_definition_spec.rb b/spec/puppet/resource_api/type_definition_spec.rb index a2df5d99..121745a6 100644 --- a/spec/puppet/resource_api/type_definition_spec.rb +++ b/spec/puppet/resource_api/type_definition_spec.rb @@ -23,22 +23,18 @@ end let(:feature_support) { [] } - it { expect { described_class.new(nil) }.to raise_error Puppet::DevError, %r{Type definition must be a Hash} } - - describe '.name' do - it { expect(type.name).to eq 'some_resource' } - end + it { expect { described_class.new(nil) }.to raise_error Puppet::DevError, %r{TypeDefinition must be a Hash} } describe '#ensurable?' do context 'when type is ensurable' do - let(:definition) { { name: 'some_resource', attributes: { ensure: { type: 'Enum[absent, present]' } } } } + let(:definition) { { name: 'ensurable', attributes: { ensure: { type: 'Enum[absent, present]' } } } } it { expect(type).to be_ensurable } it { expect(type.attributes).to be_key(:ensure) } end context 'when type is not ensurable' do - let(:definition) { { name: 'some_resource', attributes: { name: { type: 'String' } } } } + let(:definition) { { name: 'ensurable', attributes: { name: { type: 'String' } } } } it { expect(type).not_to be_ensurable } it { expect(type.attributes).to be_key(:name) } @@ -61,204 +57,17 @@ describe '#attributes' do context 'when type has attributes' do - let(:definition) { { name: 'some_resource', attributes: { wibble: { type: 'String' } } } } - - it { expect(type.attributes).to be_key(:wibble) } - end - end - - describe '#check_schema_keys' do - context 'when resource contains only valid keys' do - it 'returns an empty array' do - expect(type.check_schema_keys(definition[:attributes])).to eq([]) - end - end - - context 'when resource contains invalid keys' do - let(:resource) { { name: 'test_string', wibble: '1', foo: '2' } } - - it 'returns an array containing the bad keys' do - expect(type.check_schema_keys(resource)).to eq([:wibble, :foo]) - end - end - end - - describe '#check_schema_values' do - context 'when resource contains only valid values' do - let(:resource) { { name: 'some_resource', prop: 1, ensure: 'present' } } - - it 'returns an empty array' do - expect(type.check_schema_values(resource)).to eq({}) - end - end - - context 'when resource contains invalid values' do - let(:resource) { { name: 'test_string', prop: 'foo', ensure: 1 } } - - it 'returns a hash of the keys that have invalid values' do - expect(type.check_schema_values(resource)).to eq(prop: 'foo', ensure: 1) - end - end - end - - describe '#check_schema' do - context 'when resource does not contain its namevar' do - let(:resource) { { nom: 'some_resource', prop: 1, ensure: 'present' } } - - it { expect { type.check_schema(resource) }.to raise_error Puppet::ResourceError, %r{`some_resource.get` did not return a value for the `name` namevar attribute} } - end - - context 'when a resource contains unknown attributes' do - let(:resource) { { name: 'wibble', prop: 1, ensure: 'present', foo: 'bar' } } - let(:message) { %r{Provider returned data that does not match the Type Schema for `some_resource\[wibble\]`\n\s*Unknown attribute:\n\s*\* foo} } - let(:strict_level) { :warning } - - before(:each) do - Puppet::ResourceApi.warning_count = 0 - Puppet.settings[:strict] = strict_level - end - - context 'when puppet strict is set to default (warning)' do - it 'displays up to 100 warnings' do - expect(Puppet).to receive(:warning).with(message).exactly(100).times - 110.times do - type.check_schema(resource.dup) - end - end - end - - context 'when puppet strict is set to error' do - let(:strict_level) { :error } - - it 'raises a DevError' do - expect { type.check_schema(resource) }.to raise_error Puppet::DevError, message - end - end - - context 'when puppet strict is set to off' do - let(:strict_level) { :off } - - it 'logs to Debug console' do - expect(Puppet).to receive(:debug).with(message) - type.check_schema(resource) - end - end - end - - context 'when a resource contains invalid value' do - let(:resource) { { name: 'wibble', prop: 'foo', ensure: 'present' } } - let(:message) { %r{Provider returned data that does not match the Type Schema for `some_resource\[wibble\]`\n\s*Value type mismatch:\n\s*\* prop: foo} } - let(:strict_level) { :warning } - - before(:each) do - Puppet::ResourceApi.warning_count = 0 - Puppet.settings[:strict] = strict_level - end - - context 'when puppet strict is set to default (warning)' do - it 'displays up to 100 warnings' do - expect(Puppet).to receive(:warning).with(message).exactly(100).times - 110.times do - type.check_schema(resource.dup) - end - end - end - - context 'when puppet strict is set to error' do - let(:strict_level) { :error } - - it 'raises a DevError' do - expect { type.check_schema(resource) }.to raise_error Puppet::DevError, message - end - end - - context 'when puppet strict is set to off' do - let(:strict_level) { :off } - - it 'logs to Debug console' do - expect(Puppet).to receive(:debug).with(message) - type.check_schema(resource) - end - end + it { expect(type.attributes).to be_key(:ensure) } + it { expect(type.attributes).to be_key(:name) } + it { expect(type.attributes).to be_key(:prop) } end end describe '#validate_schema' do - context 'when the type definition does not have a name' do - let(:definition) { { attributes: 'some_string' } } - - it { expect { type }.to raise_error Puppet::DevError, %r{Type definition must have a name} } - end - - context 'when attributes is not a hash' do - let(:definition) { { name: 'some_resource', attributes: 'some_string' } } - - it { expect { type }.to raise_error Puppet::DevError, %r{`some_resource.attributes` must be a hash} } - end - context 'when the schema contains title_patterns and it is not an array' do let(:definition) { { name: 'some_resource', title_patterns: {}, attributes: {} } } it { expect { type }.to raise_error Puppet::DevError, %r{`:title_patterns` must be an array} } end - - context 'when an attribute is not a hash' do - let(:definition) { { name: 'some_resource', attributes: { name: 'some_string' } } } - - it { expect { type }.to raise_error Puppet::DevError, %r{`some_resource.name` must be a Hash} } - end - - context 'when an attribute has no type' do - let(:definition) { { name: 'some_resource', attributes: { name: { desc: 'message' } } } } - - it { expect { type }.to raise_error Puppet::DevError, %r{has no type} } - end - - context 'when an attribute has no descrption' do - let(:definition) { { name: 'some_resource', attributes: { name: { type: 'String' } } } } - - it 'Raises a warning message' do - expect(Puppet).to receive(:warning).with('`some_resource.name` has no docs') - type - end - end - - context 'when an attribute has an unsupported type' do - let(:definition) { { name: 'some_resource', attributes: { name: { type: 'basic' } } } } - - it { expect { type }.to raise_error %r{ is not a valid type specification} } - end - - context 'with both behavior and behaviour' do - let(:definition) do - { - name: 'bad_behaviour', - attributes: { - name: { - type: 'String', - behaviour: :namevar, - behavior: :namevar, - }, - }, - } - end - - it { expect { type }.to raise_error Puppet::DevError, %r{name.*attribute has both} } - end - - context 'when registering a type with badly formed attribute type' do - let(:definition) do - { - name: 'bad_syntax', - attributes: { - name: { - type: 'Optional[String', - }, - }, - } - end - - it { expect { type }.to raise_error Puppet::DevError, %r{The type of the `name` attribute `Optional\[String` could not be parsed:} } - end end end diff --git a/spec/puppet/resource_api_spec.rb b/spec/puppet/resource_api_spec.rb index 83fd72c3..d8db9afc 100644 --- a/spec/puppet/resource_api_spec.rb +++ b/spec/puppet/resource_api_spec.rb @@ -1216,6 +1216,37 @@ class OtherDevice; end it('loads the device provider') { expect(described_class.load_provider('multi_provider').name).to eq 'Puppet::Provider::MultiProvider::SomeDevice' } end end + + context 'with a transport configured' do + let(:definition) { { name: 'multi_provider', attributes: {} } } + let(:transport) { instance_double('Puppet::ResourceApi::Transport::Wrapper', 'transport') } + let(:schema_def) { instance_double('Puppet::ResourceApi::TransportSchemaDef', 'schema_def') } + + before(:each) do + allow(Puppet::Util::NetworkDevice).to receive(:current).with(no_args).and_return(transport) + allow(transport).to receive(:is_a?).with(Puppet::ResourceApi::Transport::Wrapper).and_return(true) + allow(transport).to receive(:schema).and_return(schema_def) + allow(schema_def).to receive(:name).and_return(schema_name) + + module ::Puppet::Provider::MultiProvider + class MultiProvider; end + class SomeDevice; end + class OtherDevice; end + end + end + + context 'with no device-specific provider' do + let(:schema_name) { 'multi_provider' } + + 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(:schema_name) { 'some_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 @@ -1601,6 +1632,10 @@ def set(_context, changes) stub_const('Puppet::Provider::Remoter::Remoter', provider_class) allow(provider_class).to receive(:new).and_return(provider) Puppet.settings[:strict] = :warning + + module ::Puppet::Transport + class Wibble; end + end end it 'is seen as a supported feature' do @@ -1618,6 +1653,30 @@ def set(_context, changes) expect(type.context.type).to be_feature('remote_resource') end end + + describe '#self.my_provider' do + subject(:type) { Puppet::Type.type(:remoter) } + + let(:instance) { type.new(name: 'remote_thing', test_string: 'wibble') } + let(:wrapper) { instance_double('Puppet::ResourceApi::Transport::Wrapper', 'wrapper') } + let(:transport) { instance_double('Puppet::Transport::Wibble', 'transport') } + + before(:each) do + allow(described_class).to receive(:load_provider).and_return(provider) + allow(provider).to receive(:new).and_return(provider) + end + + context 'when a transport is returned by NetworkDevice.current' do + it 'stores the provider with the the name of the transport' do + allow(Puppet::Util::NetworkDevice).to receive(:current).and_return(wrapper) + allow(wrapper).to receive(:is_a?).with(Puppet::ResourceApi::Transport::Wrapper).and_return(true) + allow(wrapper).to receive(:transport).and_return(transport) + allow(transport).to receive(:class).and_return(Puppet::Transport::Wibble) + + expect(instance.my_provider).to eq provider + end + end + end end context 'with a `supports_noop` provider', agent_test: true do @@ -1825,4 +1884,24 @@ def set(_context, changes) end it { expect { described_class.register_type(definition) }.to raise_error Puppet::ResourceError, %r{^`bad` is not a valid behaviour value$} } end end + + describe '#register_transport' do + let(:schema) do + { + name: 'test_transport', + desc: 'a demo transport', + connection_info: { + host: { + type: 'String', + desc: 'hostname', + }, + }, + } + end + + it 'calls Puppet::ResourceApi::Transport.register' do + expect(Puppet::ResourceApi::Transport).to receive(:register).with(schema) + described_class.register_transport(schema) + end + end end