From cd977e9037f94cddcf1f5c97d482b6fbe4aff5f0 Mon Sep 17 00:00:00 2001 From: Alexander Fisher Date: Fri, 12 Jun 2026 15:35:21 +0100 Subject: [PATCH] Add types and providers for Pulpcore RPM resources Added types for managing RPM 'remotes', 'repositories' and 'distributions' resources using the `pulp` CLI. Providers are layered so the common logic can be shared and the CLI implementation swapped later, (with perhaps direct calls to the REST API, or the autogenerated ruby bindings??) * A grandparent provider holds the lifecycle/prefetch/flush mechanics, the property getter/setter macros, and the href->name lookup. * A per-resource parent maps the Pulp API hash to Puppet properties. * A CLI mixin shells out to `pulp` and parses its JSON responses. * A concrete `cli` provider wires them together. Coverage is intentionally partial, starting with the properties I needed today. Adding any additional properties should be fairly straightforward. Attribution: The type and provider implementation is my own work. The unit tests were largely drafted with help from ChatGPT. The code review and the resulting cleanup refactors were co-authored with Claude, (which also helped refine the tests). Co-Authored-By: Claude Opus 4.8 --- lib/puppet/provider/pulpcore.rb | 155 ++++++++ lib/puppet/provider/pulpcore_cli.rb | 60 +++ .../provider/pulpcore_rpm_distribution.rb | 34 ++ .../provider/pulpcore_rpm_distribution/cli.rb | 60 +++ lib/puppet/provider/pulpcore_rpm_remote.rb | 65 ++++ .../provider/pulpcore_rpm_remote/cli.rb | 89 +++++ lib/puppet/provider/pulpcore_rpm_repo.rb | 37 ++ lib/puppet/provider/pulpcore_rpm_repo/cli.rb | 61 +++ lib/puppet/type/pulpcore_rpm_distribution.rb | 38 ++ lib/puppet/type/pulpcore_rpm_remote.rb | 79 ++++ lib/puppet/type/pulpcore_rpm_repo.rb | 65 ++++ lib/puppet_x/pulpcore/type_helpers.rb | 23 ++ .../unit/puppet/provider/pulpcore_cli_spec.rb | 184 +++++++++ .../pulpcore_rpm_distribution/cli_spec.rb | 283 ++++++++++++++ .../pulpcore_rpm_distribution_spec.rb | 87 +++++ .../provider/pulpcore_rpm_remote/cli_spec.rb | 358 ++++++++++++++++++ .../provider/pulpcore_rpm_remote_spec.rb | 214 +++++++++++ .../provider/pulpcore_rpm_repo/cli_spec.rb | 279 ++++++++++++++ .../puppet/provider/pulpcore_rpm_repo_spec.rb | 122 ++++++ spec/unit/puppet/provider/pulpcore_spec.rb | 298 +++++++++++++++ .../type/pulpcore_rpm_distribution_spec.rb | 113 ++++++ .../puppet/type/pulpcore_rpm_remote_spec.rb | 217 +++++++++++ .../puppet/type/pulpcore_rpm_repo_spec.rb | 165 ++++++++ 23 files changed, 3086 insertions(+) create mode 100644 lib/puppet/provider/pulpcore.rb create mode 100644 lib/puppet/provider/pulpcore_cli.rb create mode 100644 lib/puppet/provider/pulpcore_rpm_distribution.rb create mode 100644 lib/puppet/provider/pulpcore_rpm_distribution/cli.rb create mode 100644 lib/puppet/provider/pulpcore_rpm_remote.rb create mode 100644 lib/puppet/provider/pulpcore_rpm_remote/cli.rb create mode 100644 lib/puppet/provider/pulpcore_rpm_repo.rb create mode 100644 lib/puppet/provider/pulpcore_rpm_repo/cli.rb create mode 100644 lib/puppet/type/pulpcore_rpm_distribution.rb create mode 100644 lib/puppet/type/pulpcore_rpm_remote.rb create mode 100644 lib/puppet/type/pulpcore_rpm_repo.rb create mode 100644 lib/puppet_x/pulpcore/type_helpers.rb create mode 100644 spec/unit/puppet/provider/pulpcore_cli_spec.rb create mode 100644 spec/unit/puppet/provider/pulpcore_rpm_distribution/cli_spec.rb create mode 100644 spec/unit/puppet/provider/pulpcore_rpm_distribution_spec.rb create mode 100644 spec/unit/puppet/provider/pulpcore_rpm_remote/cli_spec.rb create mode 100644 spec/unit/puppet/provider/pulpcore_rpm_remote_spec.rb create mode 100644 spec/unit/puppet/provider/pulpcore_rpm_repo/cli_spec.rb create mode 100644 spec/unit/puppet/provider/pulpcore_rpm_repo_spec.rb create mode 100644 spec/unit/puppet/provider/pulpcore_spec.rb create mode 100644 spec/unit/puppet/type/pulpcore_rpm_distribution_spec.rb create mode 100644 spec/unit/puppet/type/pulpcore_rpm_remote_spec.rb create mode 100644 spec/unit/puppet/type/pulpcore_rpm_repo_spec.rb diff --git a/lib/puppet/provider/pulpcore.rb b/lib/puppet/provider/pulpcore.rb new file mode 100644 index 00000000..820aa20a --- /dev/null +++ b/lib/puppet/provider/pulpcore.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +require 'puppet/provider' + +# Base provider for logic common to all Pulpcore providers, regardless of the +# concrete implementation used to communicate with Pulp. +class Puppet::Provider::Pulpcore < Puppet::Provider + # Define property getters that read the current value from @property_hash, + # reporting an unset value as :absent (see #property_hash_value). + def self.mk_property_hash_getters(*property_names) + property_names.each do |property_name| + property_name = property_name.to_sym + + define_method(property_name) do + property_hash_value(property_name) + end + end + end + + # Define `property=` setters that record the new value in @property_flush. + def self.mk_property_flush_setters(*property_names) + property_names.each do |property_name| + property_name = property_name.to_sym + + define_method("#{property_name}=") do |value| + @property_flush[property_name] = value + end + end + end + + # Define `property=` setters for removable properties. Setting the property to + # `:absent` records an empty string in @property_flush, which concrete + # providers translate into "clear this field" when talking to Pulp. + def self.mk_absent_clearing_setters(*property_names) + property_names.each do |property_name| + property_name = property_name.to_sym + + define_method("#{property_name}=") do |value| + @property_flush[property_name] = value == :absent ? '' : value + end + end + end + + def self.resource_api_hashes + raise Puppet::DevError, "#{self} must implement .resource_api_hashes" + end + + def self.resource_api_hash(_resource_name) + raise Puppet::DevError, "#{self} must implement .resource_api_hash" + end + + def self.resource_properties_from_api_hash(_api_hash) + raise Puppet::DevError, "#{self} must implement .resource_properties_from_api_hash" + end + + def self.api_hash_by_href(_href) + raise Puppet::DevError, "#{self} must implement .api_hash_by_href" + end + + # Resolve a Pulp resource href to the referenced resource's name, returning + # :absent when no href is set. Lookups are memoised per provider class so that + # many resources referencing the same href only trigger one API call. + def self.name_by_href(href) + return :absent if href.nil? + + @name_by_href ||= {} + @name_by_href[href] ||= api_hash_by_href(href)['name'] + end + + def initialize(value = {}) + super(value) + @property_flush = {} + end + + def self.prefetch(resources) + instances.each do |prov| + if (resource = resources[prov.name]) + resource.provider = prov + end + end + end + + def self.instances + resource_api_hashes.map do |api_hash| + properties = resource_properties_from_api_hash(api_hash) + next if properties.empty? + + new(properties) + end.compact + end + + # Fetch and map the current properties for a single resource. A failed `show` + # (most commonly because the resource does not exist) raises an + # ExecutionFailure; treat that as "no properties" so the resource reads as + # absent rather than aborting the whole run. + def self.resource_properties(resource_name) + resource_properties_from_api_hash(resource_api_hash(resource_name)) + rescue Puppet::ExecutionFailure => e + warning "#resource_properties had an error -> #{e.inspect}" + {} + end + + def exists? + @property_hash[:ensure] == :present + end + + def create + @property_flush[:ensure] = :present + end + + def destroy + @property_flush[:ensure] = :absent + end + + def flush + case @property_flush[:ensure] + when :absent + delete_resource + @property_hash = {} + when :present + create_resource + update_property_hash + else + update_resource + update_property_hash + end + + @property_flush.clear + end + + def update_property_hash + @property_hash = self.class.resource_properties(resource[:name]) + end + + def create_resource + raise Puppet::DevError, "#{self.class} must implement #create_resource" + end + + def update_resource + raise Puppet::DevError, "#{self.class} must implement #update_resource" + end + + def delete_resource + raise Puppet::DevError, "#{self.class} must implement #delete_resource" + end + + private + + # Puppet treats :absent as "this property is not set", so map a missing or + # nil stored value to :absent for comparison against the desired state. + def property_hash_value(property_name) + value = @property_hash[property_name] + value.nil? ? :absent : value + end +end diff --git a/lib/puppet/provider/pulpcore_cli.rb b/lib/puppet/provider/pulpcore_cli.rb new file mode 100644 index 00000000..c3f121ea --- /dev/null +++ b/lib/puppet/provider/pulpcore_cli.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'json' +require 'tempfile' + +# Mixin for concrete providers that manage Pulp resources through the `pulp` CLI. +module Puppet::Provider::PulpcoreCli + def self.included(provider_class) + provider_class.commands pulp_binary: '/usr/bin/pulp' + provider_class.extend(ClassMethods) + end + + module ClassMethods + def pulp(*args) + pulp_binary('--format', 'json', *args) + end + + def api_hash_by_href(href) + parse_pulp_json(pulp('show', '--href', href)) + end + + # Parse a JSON response from the Pulp CLI, failing hard with a useful error + # if the output is not valid JSON (e.g. a warning line or truncated list) + # rather than letting a bare JSON::ParserError surface or silently treating + # the resource as absent. + def parse_pulp_json(response) + JSON.parse(response) + rescue JSON::ParserError => e + raise Puppet::Error, "Unable to parse the Pulp CLI JSON response (#{e.message}). Response began with: #{response.to_s.strip[0, 200].inspect}" + end + end + + def pulp(*args) + self.class.pulp(*args) + end + + # Write sensitive or large option values to temporary files and pass them to + # the Pulp CLI as @file arguments. The block must run the command while the + # files are still open. + def with_temp_file_arguments(file_arguments) + tempfiles = [] + command_arguments = [] + + begin + file_arguments.each do |option, content| + tempfile = Tempfile.new + tempfile.chmod(0o600) + tempfile.write(content) + tempfile.flush + + tempfiles << tempfile + command_arguments << option << "@#{tempfile.path}" + end + + yield command_arguments + ensure + tempfiles.each(&:close!) + end + end +end diff --git a/lib/puppet/provider/pulpcore_rpm_distribution.rb b/lib/puppet/provider/pulpcore_rpm_distribution.rb new file mode 100644 index 00000000..3bfa92dd --- /dev/null +++ b/lib/puppet/provider/pulpcore_rpm_distribution.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require_relative 'pulpcore' + +# Abstract provider for Pulpcore RPM distribution resources. +# +# This class contains behaviour common to all `pulpcore_rpm_distribution` +# providers. Concrete implementations, such as the `cli` provider, inherit +# from it. +class Puppet::Provider::PulpcoreRpmDistribution < Puppet::Provider::Pulpcore + mk_property_hash_getters( + :base_path, + :repo, + :checkpoint + ) + mk_property_flush_setters(:base_path, :checkpoint) + mk_absent_clearing_setters(:repo) + + def self.resource_properties_from_api_hash(distribution_properties) + resource_properties = { + name: distribution_properties['name'], + ensure: :present, + provider: name, + + base_path: distribution_properties['base_path'], + repo: name_by_href(distribution_properties['repository']), + checkpoint: distribution_properties['checkpoint'] ? :true : :false + } + + debug "Distribution resource properties: #{resource_properties.inspect}" + + resource_properties + end +end diff --git a/lib/puppet/provider/pulpcore_rpm_distribution/cli.rb b/lib/puppet/provider/pulpcore_rpm_distribution/cli.rb new file mode 100644 index 00000000..e34240fd --- /dev/null +++ b/lib/puppet/provider/pulpcore_rpm_distribution/cli.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require_relative '../pulpcore_cli' +require_relative '../pulpcore_rpm_distribution' + +Puppet::Type.type(:pulpcore_rpm_distribution).provide(:cli, parent: Puppet::Provider::PulpcoreRpmDistribution) do + include Puppet::Provider::PulpcoreCli + + def self.resource_api_hashes + parse_pulp_json(pulp('rpm', 'distribution', 'list', '--limit', 1_000_000)) + end + + def self.resource_api_hash(distribution_name) + parse_pulp_json(pulp('rpm', 'distribution', 'show', '--name', distribution_name)) + end + + # base_path is mandatory to create a distribution but optional once it exists + # (you may manage only a subset of properties), so it can't be a required + # type-level param. Enforce it here, at create time. + def create_resource + raise ArgumentError, '`base_path` is a required property when creating a `Pulpcore_rpm_distribution` resource.' unless resource[:base_path] + + command_arguments = [ + 'rpm', 'distribution', 'create', + '--name', resource[:name], + '--base-path', resource[:base_path] + ] + + command_arguments << '--repository' << resource[:repo] if resource[:repo] && resource[:repo] != :absent + + case resource[:checkpoint] + when :true + command_arguments << '--checkpoint' + when :false + command_arguments << '--not-checkpoint' + end + + pulp(*command_arguments) + end + + def update_resource + command_arguments = ['rpm', 'distribution', 'update', '--name', resource[:name]] + + command_arguments << '--base-path' << @property_flush[:base_path] if @property_flush.key?(:base_path) + command_arguments << '--repository' << @property_flush[:repo] if @property_flush.key?(:repo) + + case @property_flush[:checkpoint] + when :true + command_arguments << '--checkpoint' + when :false + command_arguments << '--not-checkpoint' + end + + pulp(*command_arguments) + end + + def delete_resource + pulp('rpm', 'distribution', 'destroy', '--name', resource[:name]) + end +end diff --git a/lib/puppet/provider/pulpcore_rpm_remote.rb b/lib/puppet/provider/pulpcore_rpm_remote.rb new file mode 100644 index 00000000..f90c5829 --- /dev/null +++ b/lib/puppet/provider/pulpcore_rpm_remote.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require_relative 'pulpcore' + +# Abstract provider for Pulpcore RPM remote resources. +# +# This class contains behaviour common to all `pulpcore_rpm_remote` providers. +# Concrete implementations, such as the `cli` provider, inherit from it. +class Puppet::Provider::PulpcoreRpmRemote < Puppet::Provider::Pulpcore + mk_property_hash_getters( + :url, + :policy, + :tls_validation, + :client_cert, + :ca_cert + ) + mk_property_flush_setters(:url, :policy, :tls_validation) + mk_absent_clearing_setters(:client_cert, :ca_cert) + + def self.resource_properties_from_api_hash(remote_properties) + resource_properties = { + name: remote_properties['name'], + ensure: :present, + provider: name, + + url: remote_properties['url'], + policy: remote_properties['policy'], + tls_validation: remote_properties['tls_validation'] ? :true : :false, + + client_cert: remote_properties['client_cert'] || :absent, + ca_cert: remote_properties['ca_cert'] || :absent, + + # Internal provider state, not a Puppet property. + client_key_set: hidden_field_set?(remote_properties, 'client_key') + } + + debug "Remote resource properties: #{resource_properties.inspect}" + + resource_properties + end + + # Pulp never returns secret values (such as client_key) in an API response. + # Instead each appears in the `hidden_fields` array with an `is_set` flag + # reporting whether a value is currently stored. Return that flag for the + # named field, raising if the response isn't shaped as expected. + def self.hidden_field_set?(api_hash, field_name) + hidden_fields = api_hash.fetch('hidden_fields') do + raise Puppet::Error, 'Pulp API response did not include hidden_fields.' + end + + raise Puppet::Error, 'Pulp API response hidden_fields was nil.' if hidden_fields.nil? + + hidden_field = hidden_fields.find do |field| + field['name'] == field_name + end + + raise Puppet::Error, "Pulp API response did not include #{field_name} in hidden_fields." unless hidden_field + + hidden_field['is_set'] + end + + def client_key_set? + @property_hash[:client_key_set] == true + end +end diff --git a/lib/puppet/provider/pulpcore_rpm_remote/cli.rb b/lib/puppet/provider/pulpcore_rpm_remote/cli.rb new file mode 100644 index 00000000..4dcdc8fa --- /dev/null +++ b/lib/puppet/provider/pulpcore_rpm_remote/cli.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require_relative '../pulpcore_cli' +require_relative '../pulpcore_rpm_remote' + +Puppet::Type.type(:pulpcore_rpm_remote).provide(:cli, parent: Puppet::Provider::PulpcoreRpmRemote) do + include Puppet::Provider::PulpcoreCli + + def self.resource_api_hashes + parse_pulp_json(pulp('rpm', 'remote', 'list', '--limit', 1_000_000)) + end + + def self.resource_api_hash(remote_name) + parse_pulp_json(pulp('rpm', 'remote', 'show', '--name', remote_name)) + end + + # url is mandatory to create a remote but optional once it exists (you may + # manage only a subset of properties), so it can't be a required type-level + # param. Enforce it here, at create time. + def create_resource + raise ArgumentError, '`url` is a required property when creating a `Pulpcore_rpm_remote` resource.' unless resource[:url] + + command_arguments = ['rpm', 'remote', 'create', '--name', resource[:name], '--url', resource[:url]] + file_arguments = [] + + command_arguments << '--policy' << resource[:policy] if resource[:policy] + command_arguments << '--tls-validation' << resource[:tls_validation].to_s if resource[:tls_validation] + + if resource[:client_cert] && resource[:client_cert] != :absent + file_arguments << ['--client-cert', resource[:client_cert]] + file_arguments << ['--client-key', required_client_key] + end + + file_arguments << ['--ca-cert', resource[:ca_cert]] if resource[:ca_cert] && resource[:ca_cert] != :absent + + with_temp_file_arguments(file_arguments) do |temporary_file_arguments| + pulp(*(command_arguments + temporary_file_arguments)) + end + end + + def update_resource + command_arguments = ['rpm', 'remote', 'update', '--name', resource[:name]] + file_arguments = [] + + command_arguments << '--url' << @property_flush[:url] if @property_flush.key?(:url) + command_arguments << '--policy' << @property_flush[:policy] if @property_flush.key?(:policy) + + command_arguments << '--tls-validation' << @property_flush[:tls_validation].to_s if @property_flush.key?(:tls_validation) + + if @property_flush.key?(:client_cert) + if @property_flush[:client_cert] == '' + command_arguments << '--client-cert' << '' + command_arguments << '--client-key' << '' + else + file_arguments << ['--client-cert', @property_flush[:client_cert]] + file_arguments << ['--client-key', required_client_key] + end + end + + if @property_flush.key?(:ca_cert) + if @property_flush[:ca_cert] == '' + command_arguments << '--ca-cert' << '' + else + file_arguments << ['--ca-cert', @property_flush[:ca_cert]] + end + end + + with_temp_file_arguments(file_arguments) do |temporary_file_arguments| + pulp(*(command_arguments + temporary_file_arguments)) + end + end + + def delete_resource + pulp('rpm', 'remote', 'destroy', '--name', resource[:name]) + end + + private + + # The type's validate block already guarantees client_key is present whenever + # client_cert is set, so a missing key here is an internal error rather than + # bad user input, hence Puppet::DevError rather than ArgumentError. + def required_client_key + client_key = resource[:client_key] + + return client_key if client_key && client_key != :absent + + raise Puppet::DevError, '`client_key` was required while setting `client_cert`, but was not present.' + end +end diff --git a/lib/puppet/provider/pulpcore_rpm_repo.rb b/lib/puppet/provider/pulpcore_rpm_repo.rb new file mode 100644 index 00000000..6c3cab9b --- /dev/null +++ b/lib/puppet/provider/pulpcore_rpm_repo.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require_relative 'pulpcore' + +# Abstract provider for Pulpcore RPM repository resources. +# +# This class contains behaviour common to all `pulpcore_rpm_repo` providers. +# Concrete implementations, such as the `cli` provider, inherit from it. +class Puppet::Provider::PulpcoreRpmRepo < Puppet::Provider::Pulpcore + mk_property_hash_getters( + :autopublish, + :description, + :remote, + :retain_package_versions, + :retain_repo_versions + ) + mk_property_flush_setters(:autopublish, :retain_package_versions) + mk_absent_clearing_setters(:description, :remote, :retain_repo_versions) + + def self.resource_properties_from_api_hash(repo_properties) + resource_properties = { + name: repo_properties['name'], + ensure: :present, + provider: name, + + description: repo_properties['description'] || :absent, + remote: name_by_href(repo_properties['remote']), + retain_package_versions: repo_properties['retain_package_versions'], + retain_repo_versions: repo_properties['retain_repo_versions'] || :absent, + autopublish: repo_properties['autopublish'] ? :true : :false + } + + debug "Repository resource properties: #{resource_properties.inspect}" + + resource_properties + end +end diff --git a/lib/puppet/provider/pulpcore_rpm_repo/cli.rb b/lib/puppet/provider/pulpcore_rpm_repo/cli.rb new file mode 100644 index 00000000..a1d094a7 --- /dev/null +++ b/lib/puppet/provider/pulpcore_rpm_repo/cli.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require_relative '../pulpcore_cli' +require_relative '../pulpcore_rpm_repo' + +Puppet::Type.type(:pulpcore_rpm_repo).provide(:cli, parent: Puppet::Provider::PulpcoreRpmRepo) do + include Puppet::Provider::PulpcoreCli + + def self.resource_api_hashes + parse_pulp_json(pulp('rpm', 'repository', 'list', '--limit', 1_000_000)) + end + + def self.resource_api_hash(repo_name) + parse_pulp_json(pulp('rpm', 'repository', 'show', '--name', repo_name)) + end + + def create_resource + command_arguments = ['rpm', 'repository', 'create', '--name', resource[:name]] + + command_arguments << '--description' << resource[:description] if resource[:description] && resource[:description] != :absent + + command_arguments << '--remote' << resource[:remote] if resource[:remote] && resource[:remote] != :absent + + command_arguments << '--retain-package-versions' << resource[:retain_package_versions] if resource[:retain_package_versions] + + command_arguments << '--retain-repo-versions' << resource[:retain_repo_versions] if resource[:retain_repo_versions] && resource[:retain_repo_versions] != :absent + + case resource[:autopublish] + when :true + command_arguments << '--autopublish' + when :false + command_arguments << '--no-autopublish' + end + + pulp(*command_arguments) + end + + def update_resource + command_arguments = ['rpm', 'repository', 'update', '--name', resource[:name]] + + command_arguments << '--description' << @property_flush[:description] if @property_flush.key?(:description) + command_arguments << '--remote' << @property_flush[:remote] if @property_flush.key?(:remote) + + command_arguments << '--retain-package-versions' << @property_flush[:retain_package_versions] if @property_flush.key?(:retain_package_versions) + + command_arguments << '--retain-repo-versions' << @property_flush[:retain_repo_versions] if @property_flush.key?(:retain_repo_versions) + + case @property_flush[:autopublish] + when :true + command_arguments << '--autopublish' + when :false + command_arguments << '--no-autopublish' + end + + pulp(*command_arguments) + end + + def delete_resource + pulp('rpm', 'repository', 'destroy', '--name', resource[:name]) + end +end diff --git a/lib/puppet/type/pulpcore_rpm_distribution.rb b/lib/puppet/type/pulpcore_rpm_distribution.rb new file mode 100644 index 00000000..69919174 --- /dev/null +++ b/lib/puppet/type/pulpcore_rpm_distribution.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require_relative '../../puppet_x/pulpcore/type_helpers' + +Puppet::Type.newtype(:pulpcore_rpm_distribution) do + include PuppetX::Pulpcore::TypeHelpers + + ensurable + + newparam(:name, namevar: true) + + newproperty(:base_path) do + desc 'The base path component of the URL at which the distribution is served.' + + newvalue(%r{\A.+\z}) + end + + newproperty(:repo) do + desc 'The name of the repository to be used for auto-distributing. Set to `absent` to remove.' + + newvalue(:absent) + newvalue(%r{\A.+\z}) + end + + newproperty(:checkpoint) do + desc 'Whether this distribution should host `checkpoint` publications or not.' + + munge { |value| @resource.munge_boolean_to_symbol(value) } + end + + autorequire(:pulpcore_rpm_repo) do + if self[:repo] && self[:repo] != :absent + [self[:repo]] + else + [] + end + end +end diff --git a/lib/puppet/type/pulpcore_rpm_remote.rb b/lib/puppet/type/pulpcore_rpm_remote.rb new file mode 100644 index 00000000..acb61bf9 --- /dev/null +++ b/lib/puppet/type/pulpcore_rpm_remote.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require_relative '../../puppet_x/pulpcore/type_helpers' + +Puppet::Type.newtype(:pulpcore_rpm_remote) do + include PuppetX::Pulpcore::TypeHelpers + + ensurable + + newparam(:name, namevar: true) + + newproperty(:url) do + desc 'The URL for the remote.' + + newvalue(%r{\A(https?|uln)://.+\z}) + end + + newproperty(:policy) do + desc 'One of `immediate`, `on_demand` or `streamed`.' + + newvalue(%r{\A(immediate|on_demand|streamed)\z}) + end + + newproperty(:tls_validation) do + munge { |value| @resource.munge_boolean_to_symbol(value) } + end + + newproperty(:client_cert) do + desc 'A PEM encoded client certificate used for authentication. When set, the `client_key` parameter must also be specified. Set to `absent` to remove.' + + newvalue(:absent) + newvalue(%r{\A-----BEGIN CERTIFICATE-----\n.+-----END CERTIFICATE-----\n?\z}m) + + # Pulp does not return the client_key value, but hidden_fields tells us + # whether one is set. Treat the cert and hidden key as a pair for sync. + def insync?(is) + client_cert_in_sync = super(is) + + if should == :absent + client_cert_in_sync && !resource.provider.client_key_set? + else + client_cert_in_sync && resource.provider.client_key_set? + end + end + end + + # The Pulp API doesn't return the client_key, so it's not something we can check is 'in sync'. + # As such, it's made a parameter here, not a _property_. + newparam(:client_key) do + desc 'A PEM encoded private key used for authentication. Required when `client_cert` is set, (and not `absent`).' + + newvalues(:absent, %r{\A-----BEGIN PRIVATE KEY-----\n.+-----END PRIVATE KEY-----\n?\z}m) + end + + newproperty(:ca_cert) do + desc 'A PEM encoded CA certificate used to validate the server certificate presented by the remote server. Set to `absent` to remove.' + + newvalue(:absent) + newvalue(%r{\A-----BEGIN CERTIFICATE-----\n.+-----END CERTIFICATE-----\n?\z}m) + end + + validate do + if self[:client_cert] && self[:client_cert] != :absent && (!self[:client_key] || self[:client_key] == :absent) + raise Puppet::Error, + 'pulpcore_rpm_remote: `client_key` is required when `client_cert` is set.' + end + end + + private + + # client_key carries private key material, so always mark it sensitive. This + # redacts it from logs, reports and `puppet resource` output even when the + # user hasn't wrapped it in Sensitive() themselves. + def set_sensitive_parameters(sensitive_parameters) # rubocop:disable Naming/AccessorMethodName + parameter(:client_key).sensitive = true if parameter(:client_key) + + super(sensitive_parameters) + end +end diff --git a/lib/puppet/type/pulpcore_rpm_repo.rb b/lib/puppet/type/pulpcore_rpm_repo.rb new file mode 100644 index 00000000..d95dbddd --- /dev/null +++ b/lib/puppet/type/pulpcore_rpm_repo.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require_relative '../../puppet_x/pulpcore/type_helpers' + +Puppet::Type.newtype(:pulpcore_rpm_repo) do + include PuppetX::Pulpcore::TypeHelpers + + ensurable + + newparam(:name, namevar: true) + + newproperty(:description) do + desc 'The description. Set to `absent` to remove the description.' + + newvalue(:absent) + newvalue(%r{\A.+\z}) + end + + newproperty(:remote) do + desc 'The name of a remote to configure on this repository. Set to `absent` to remove.' + + newvalue(:absent) + newvalue(%r{\A.+\z}) + end + + newproperty(:retain_package_versions) do + desc 'The maximum number of versions of each package to keep. A value of 0 means "unlimited".' + + newvalue(%r{\A\d+\z}) + + munge do |value| + Integer(value) + end + end + + newproperty(:retain_repo_versions) do + desc 'Specifies how many repository versions will be kept for a repository. Set to `absent` to remove the setting.' + + newvalue(:absent) + newvalue(%r{\A\d+\z}) + + munge do |value| + case value + when 'absent', :absent + :absent + else + Integer(value) + end + end + end + + newproperty(:autopublish) do + desc 'If set to True, Pulp will automatically create publications for new repository versions.' + + munge { |value| @resource.munge_boolean_to_symbol(value) } + end + + autorequire(:pulpcore_rpm_remote) do + if self[:remote] && self[:remote] != :absent + [self[:remote]] + else + [] + end + end +end diff --git a/lib/puppet_x/pulpcore/type_helpers.rb b/lib/puppet_x/pulpcore/type_helpers.rb new file mode 100644 index 00000000..045e013c --- /dev/null +++ b/lib/puppet_x/pulpcore/type_helpers.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module PuppetX + module Pulpcore + # Helpers shared across the Pulpcore Puppet types. + module TypeHelpers + # Munge truthy/falsey user input to the :true / :false symbols the + # providers use internally. + def munge_boolean_to_symbol(value) + value = value.downcase if value.respond_to? :downcase + + case value + when true, :true, 'true', :yes, 'yes' + :true + when false, :false, 'false', :no, 'no' + :false + else + raise ArgumentError, 'expected a boolean value' + end + end + end + end +end diff --git a/spec/unit/puppet/provider/pulpcore_cli_spec.rb b/spec/unit/puppet/provider/pulpcore_cli_spec.rb new file mode 100644 index 00000000..92647418 --- /dev/null +++ b/spec/unit/puppet/provider/pulpcore_cli_spec.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'puppet/provider/pulpcore_cli' + +# Define a tiny test-only type/provider so the mixin is included into a real +# Puppet provider class. +Puppet::Type.newtype(:pulpcore_cli_test) do + newparam(:name, namevar: true) +end + +Puppet::Type.type(:pulpcore_cli_test).provide(:test) do + include Puppet::Provider::PulpcoreCli +end + +describe Puppet::Provider::PulpcoreCli do + let(:provider_class) { Puppet::Type.type(:pulpcore_cli_test).provider(:test) } + let(:provider) { provider_class.new(name: 'test') } + let(:client_cert) { "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n" } + let(:client_key) { "-----BEGIN PRIVATE KEY-----\nMIIB\n-----END PRIVATE KEY-----\n" } + + describe '.pulp' do + it 'adds JSON formatting before the supplied Pulp CLI arguments' do + allow(provider_class).to receive(:pulp_binary).and_return('[]') + + expect(provider_class.pulp('rpm', 'remote', 'list')).to eq('[]') + expect(provider_class).to have_received(:pulp_binary).with( + '--format', 'json', + 'rpm', 'remote', 'list' + ) + end + end + + describe '.api_hash_by_href' do + it 'shows an object by href and parses the JSON response' do + api_hash = { 'name' => 'test_remote' } + + allow(provider_class).to receive(:pulp).and_return(JSON.generate(api_hash)) + + expect(provider_class.api_hash_by_href('/pulp/api/v3/remotes/rpm/rpm/test/')).to eq(api_hash) + expect(provider_class).to have_received(:pulp).with( + 'show', + '--href', + '/pulp/api/v3/remotes/rpm/rpm/test/' + ) + end + end + + describe '.parse_pulp_json' do + it 'parses a valid JSON response' do + expect(provider_class.parse_pulp_json('{"name":"test_remote"}')).to eq('name' => 'test_remote') + end + + it 'raises a Puppet::Error when the response is not valid JSON' do + expect do + provider_class.parse_pulp_json("Warning: deprecated\n") + end.to raise_error(Puppet::Error, %r{Unable to parse the Pulp CLI JSON response}) + end + + it 'includes the start of the offending response in the error' do + expect do + provider_class.parse_pulp_json("Warning: deprecated\n") + end.to raise_error(Puppet::Error, %r{Warning: deprecated}) + end + end + + describe '#pulp' do + it 'delegates to the provider class pulp method' do + allow(provider_class).to receive(:pulp).and_return('[]') + + expect(provider.pulp('rpm', 'remote', 'list')).to eq('[]') + expect(provider_class).to have_received(:pulp).with('rpm', 'remote', 'list') + end + end + + describe '#with_temp_file_arguments' do + def temporary_paths(arguments) + arguments.each_slice(2).map do |_option, file_argument| + file_argument.delete_prefix('@') + end + end + + def collect_temp_file_details(provider, file_arguments) + details = {} + + provider.with_temp_file_arguments(file_arguments) do |arguments| + paths = temporary_paths(arguments) + + details[:arguments] = arguments + details[:paths] = paths + details[:contents] = paths.map { |path| File.read(path) } + details[:modes] = paths.map { |path| File.stat(path).mode & 0o777 } + details[:exists_during_yield] = paths.map { |path| File.exist?(path) } + end + + details + end + + it 'yields command arguments using @file paths' do + details = collect_temp_file_details( + provider, + [ + ['--client-cert', client_cert], + ['--client-key', client_key] + ] + ) + + expect(details[:arguments].values_at(0, 2)).to eq(['--client-cert', '--client-key']) + expect(details[:arguments].values_at(1, 3)).to all(start_with('@')) + end + + it 'writes supplied content to the temporary files' do + details = collect_temp_file_details( + provider, + [ + ['--client-cert', client_cert], + ['--client-key', client_key] + ] + ) + + expect(details[:contents]).to eq([client_cert, client_key]) + end + + it 'uses owner-only file permissions' do + details = collect_temp_file_details( + provider, + [ + ['--client-cert', client_cert], + ['--client-key', client_key] + ] + ) + + expect(details[:modes]).to eq([0o600, 0o600]) + end + + it 'keeps the temporary files available while the block is running' do + details = collect_temp_file_details( + provider, + [ + ['--client-cert', client_cert], + ['--client-key', client_key] + ] + ) + + expect(details[:exists_during_yield]).to eq([true, true]) + end + + it 'removes the temporary files after the block exits' do + details = collect_temp_file_details( + provider, + [ + ['--client-cert', client_cert], + ['--client-key', client_key] + ] + ) + + expect(details[:paths].map { |path| File.exist?(path) }).to eq([false, false]) + end + + it 'removes the temporary files when the block raises' do + paths = [] + + expect do + provider.with_temp_file_arguments([['--client-cert', client_cert]]) do |arguments| + paths = temporary_paths(arguments) + + raise 'test error' + end + end.to raise_error(RuntimeError, 'test error') + + expect(paths.map { |path| File.exist?(path) }).to eq([false]) + end + + it 'yields an empty argument list when there are no file arguments' do + yielded_arguments = nil + + provider.with_temp_file_arguments([]) do |arguments| + yielded_arguments = arguments + end + + expect(yielded_arguments).to eq([]) + end + end +end diff --git a/spec/unit/puppet/provider/pulpcore_rpm_distribution/cli_spec.rb b/spec/unit/puppet/provider/pulpcore_rpm_distribution/cli_spec.rb new file mode 100644 index 00000000..36573422 --- /dev/null +++ b/spec/unit/puppet/provider/pulpcore_rpm_distribution/cli_spec.rb @@ -0,0 +1,283 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'puppet/provider/pulpcore_rpm_distribution/cli' + +describe Puppet::Type.type(:pulpcore_rpm_distribution).provider(:cli) do + let(:resource_name) { 'test_distribution' } + let(:base_path) { 'rpm/test' } + let(:updated_base_path) { 'rpm/updated' } + let(:repo_name) { 'test_repo' } + + def new_resource(attributes = {}) + Puppet::Type.type(:pulpcore_rpm_distribution).new( + { + name: resource_name, + provider: :cli + }.merge(attributes) + ) + end + + def new_provider(attributes = {}) + described_class.new(name: resource_name).tap do |provider| + provider.resource = new_resource(attributes) + end + end + + def stub_pulp(provider) + allow(provider).to receive(:pulp) + end + + describe '.resource_api_hashes' do + it 'lists RPM distributions through the Pulp CLI and parses the JSON response' do + api_hashes = [ + { 'name' => resource_name } + ] + + allow(described_class).to receive(:pulp).and_return(JSON.generate(api_hashes)) + + expect(described_class.resource_api_hashes).to eq(api_hashes) + expect(described_class).to have_received(:pulp).with( + 'rpm', + 'distribution', + 'list', + '--limit', + 1_000_000 + ) + end + end + + describe '.resource_api_hash' do + it 'shows an RPM distribution by name and parses the JSON response' do + api_hash = { 'name' => resource_name } + + allow(described_class).to receive(:pulp).and_return(JSON.generate(api_hash)) + + expect(described_class.resource_api_hash(resource_name)).to eq(api_hash) + expect(described_class).to have_received(:pulp).with( + 'rpm', + 'distribution', + 'show', + '--name', + resource_name + ) + end + end + + describe '#create_resource' do + it 'raises when base_path is missing during create' do + provider = new_provider + + expect do + provider.create_resource + end.to raise_error(ArgumentError, %r{base_path.*required}) + end + + it 'creates a distribution with the required arguments' do + provider = new_provider(base_path: base_path) + + stub_pulp(provider) + + provider.create_resource + + expect(provider).to have_received(:pulp).with( + 'rpm', + 'distribution', + 'create', + '--name', + resource_name, + '--base-path', + base_path + ) + end + + it 'includes repository when repo is set' do + provider = new_provider( + base_path: base_path, + repo: repo_name + ) + + stub_pulp(provider) + + provider.create_resource + + expect(provider).to have_received(:pulp).with( + 'rpm', + 'distribution', + 'create', + '--name', + resource_name, + '--base-path', + base_path, + '--repository', + repo_name + ) + end + + it 'does not include repository when repo is absent' do + provider = new_provider( + base_path: base_path, + repo: :absent + ) + + stub_pulp(provider) + + provider.create_resource + + expect(provider).to have_received(:pulp).with( + 'rpm', + 'distribution', + 'create', + '--name', + resource_name, + '--base-path', + base_path + ) + end + + it 'uses --checkpoint when checkpoint is true' do + provider = new_provider( + base_path: base_path, + checkpoint: true + ) + + stub_pulp(provider) + + provider.create_resource + + expect(provider).to have_received(:pulp).with( + 'rpm', + 'distribution', + 'create', + '--name', + resource_name, + '--base-path', + base_path, + '--checkpoint' + ) + end + + it 'uses --not-checkpoint when checkpoint is false' do + provider = new_provider( + base_path: base_path, + checkpoint: false + ) + + stub_pulp(provider) + + provider.create_resource + + expect(provider).to have_received(:pulp).with( + 'rpm', + 'distribution', + 'create', + '--name', + resource_name, + '--base-path', + base_path, + '--not-checkpoint' + ) + end + end + + describe '#update_resource' do + it 'updates only flushed scalar properties' do + provider = new_provider + + provider.base_path = updated_base_path + provider.repo = repo_name + + stub_pulp(provider) + + provider.update_resource + + expect(provider).to have_received(:pulp).with( + 'rpm', + 'distribution', + 'update', + '--name', + resource_name, + '--base-path', + updated_base_path, + '--repository', + repo_name + ) + end + + it 'clears repo using an empty string' do + provider = new_provider + + provider.repo = :absent + + stub_pulp(provider) + + provider.update_resource + + expect(provider).to have_received(:pulp).with( + 'rpm', + 'distribution', + 'update', + '--name', + resource_name, + '--repository', + '' + ) + end + + it 'uses --checkpoint when checkpoint is true' do + provider = new_provider + + provider.checkpoint = :true + + stub_pulp(provider) + + provider.update_resource + + expect(provider).to have_received(:pulp).with( + 'rpm', + 'distribution', + 'update', + '--name', + resource_name, + '--checkpoint' + ) + end + + it 'uses --not-checkpoint when checkpoint is false' do + provider = new_provider + + provider.checkpoint = :false + + stub_pulp(provider) + + provider.update_resource + + expect(provider).to have_received(:pulp).with( + 'rpm', + 'distribution', + 'update', + '--name', + resource_name, + '--not-checkpoint' + ) + end + end + + describe '#delete_resource' do + it 'destroys the distribution by name' do + provider = new_provider + + stub_pulp(provider) + + provider.delete_resource + + expect(provider).to have_received(:pulp).with( + 'rpm', + 'distribution', + 'destroy', + '--name', + resource_name + ) + end + end +end diff --git a/spec/unit/puppet/provider/pulpcore_rpm_distribution_spec.rb b/spec/unit/puppet/provider/pulpcore_rpm_distribution_spec.rb new file mode 100644 index 00000000..a9f48347 --- /dev/null +++ b/spec/unit/puppet/provider/pulpcore_rpm_distribution_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'puppet/provider/pulpcore_rpm_distribution' + +describe Puppet::Provider::PulpcoreRpmDistribution do + let(:resource_name) { 'test_distribution' } + let(:base_path) { 'rpm/test' } + let(:repo_name) { 'test_repo' } + let(:repo_href) { '/pulp/api/v3/repositories/rpm/rpm/test_repo/' } + let(:provider) { described_class.new(name: resource_name, ensure: :present) } + + def property_flush(provider) + provider.instance_variable_get(:@property_flush) + end + + before do + described_class.instance_variable_set(:@name_by_href, {}) + end + + describe '.resource_properties_from_api_hash' do + def distribution_api_hash + { + 'name' => resource_name, + 'base_path' => base_path, + 'repository' => repo_href, + 'checkpoint' => true + } + end + + it 'maps Pulp API fields to Puppet provider properties' do + allow(described_class).to receive(:api_hash_by_href).and_return('name' => repo_name) + + expect(described_class.resource_properties_from_api_hash(distribution_api_hash)).to eq( + name: resource_name, + ensure: :present, + provider: described_class.name, + base_path: base_path, + repo: repo_name, + checkpoint: :true + ) + + expect(described_class).to have_received(:api_hash_by_href).with(repo_href) + end + + it 'maps nil repository to :absent' do + api_hash = distribution_api_hash + api_hash['repository'] = nil + + expect(described_class.resource_properties_from_api_hash(api_hash)[:repo]).to eq(:absent) + end + + it 'maps false checkpoint to :false' do + api_hash = distribution_api_hash + api_hash['repository'] = nil + api_hash['checkpoint'] = false + + expect(described_class.resource_properties_from_api_hash(api_hash)[:checkpoint]).to eq(:false) + end + end + + describe 'property setters' do + it 'stores base_path changes in property_flush' do + provider.base_path = base_path + + expect(property_flush(provider)).to eq(base_path: base_path) + end + + it 'stores repo changes in property_flush' do + provider.repo = repo_name + + expect(property_flush(provider)).to eq(repo: repo_name) + end + + it 'stores an empty string when repo is set to absent' do + provider.repo = :absent + + expect(property_flush(provider)).to eq(repo: '') + end + + it 'stores checkpoint changes in property_flush' do + provider.checkpoint = :true + + expect(property_flush(provider)).to eq(checkpoint: :true) + end + end +end diff --git a/spec/unit/puppet/provider/pulpcore_rpm_remote/cli_spec.rb b/spec/unit/puppet/provider/pulpcore_rpm_remote/cli_spec.rb new file mode 100644 index 00000000..0c4fc091 --- /dev/null +++ b/spec/unit/puppet/provider/pulpcore_rpm_remote/cli_spec.rb @@ -0,0 +1,358 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'puppet/provider/pulpcore_rpm_remote/cli' + +describe Puppet::Type.type(:pulpcore_rpm_remote).provider(:cli) do + let(:resource_name) { 'test_remote' } + let(:url) { 'https://example.com/pulp/content/test/' } + let(:updated_url) { 'https://mirror.example.com/pulp/content/test/' } + let(:policy) { 'on_demand' } + let(:client_cert) { "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n" } + let(:client_key) { "-----BEGIN PRIVATE KEY-----\nMIIB\n-----END PRIVATE KEY-----\n" } + let(:ca_cert) { "-----BEGIN CERTIFICATE-----\nMIIC\n-----END CERTIFICATE-----\n" } + + def new_resource(attributes = {}) + Puppet::Type.type(:pulpcore_rpm_remote).new( + { + name: resource_name, + provider: :cli + }.merge(attributes) + ) + end + + def new_provider(attributes = {}) + described_class.new(name: resource_name).tap do |provider| + provider.resource = new_resource(attributes) + end + end + + def stub_temp_file_arguments(provider, temporary_file_arguments = []) + allow(provider).to receive(:with_temp_file_arguments) do |_file_arguments, &block| + block.call(temporary_file_arguments) + end + end + + def stub_pulp(provider) + allow(provider).to receive(:pulp) + end + + describe '.resource_api_hashes' do + it 'lists RPM remotes through the Pulp CLI and parses the JSON response' do + api_hashes = [ + { 'name' => resource_name } + ] + + allow(described_class).to receive(:pulp).and_return(JSON.generate(api_hashes)) + + expect(described_class.resource_api_hashes).to eq(api_hashes) + expect(described_class).to have_received(:pulp).with('rpm', 'remote', 'list', '--limit', 1_000_000) + end + end + + describe '.resource_api_hash' do + it 'shows an RPM remote by name and parses the JSON response' do + api_hash = { 'name' => resource_name } + + allow(described_class).to receive(:pulp).and_return(JSON.generate(api_hash)) + + expect(described_class.resource_api_hash(resource_name)).to eq(api_hash) + expect(described_class).to have_received(:pulp).with('rpm', 'remote', 'show', '--name', resource_name) + end + end + + describe '#create_resource' do + it 'raises when url is missing during create' do + provider = new_provider + + expect do + provider.create_resource + end.to raise_error(ArgumentError, %r{url.*required}) + end + + it 'creates a remote with the required arguments' do + provider = new_provider(url: url) + + stub_temp_file_arguments(provider) + stub_pulp(provider) + + provider.create_resource + + expect(provider).to have_received(:with_temp_file_arguments).with([]) + expect(provider).to have_received(:pulp).with( + 'rpm', 'remote', 'create', + '--name', resource_name, + '--url', url + ) + end + + it 'includes policy when set' do + provider = new_provider(url: url, policy: policy) + + stub_temp_file_arguments(provider) + stub_pulp(provider) + + provider.create_resource + + expect(provider).to have_received(:with_temp_file_arguments).with([]) + expect(provider).to have_received(:pulp).with( + 'rpm', 'remote', 'create', + '--name', resource_name, + '--url', url, + '--policy', policy + ) + end + + it 'includes tls_validation when set' do + provider = new_provider(url: url, tls_validation: false) + + stub_temp_file_arguments(provider) + stub_pulp(provider) + + provider.create_resource + + expect(provider).to have_received(:with_temp_file_arguments).with([]) + expect(provider).to have_received(:pulp).with( + 'rpm', 'remote', 'create', + '--name', resource_name, + '--url', url, + '--tls-validation', 'false' + ) + end + + it 'passes client certificate and key through temporary file arguments' do + provider = new_provider( + url: url, + client_cert: client_cert, + client_key: client_key + ) + + file_arguments = [ + ['--client-cert', client_cert], + ['--client-key', client_key] + ] + + stub_temp_file_arguments( + provider, + [ + '--client-cert', '@client-cert-file', + '--client-key', '@client-key-file' + ] + ) + stub_pulp(provider) + + provider.create_resource + + expect(provider).to have_received(:with_temp_file_arguments).with(file_arguments) + expect(provider).to have_received(:pulp).with( + 'rpm', 'remote', 'create', + '--name', resource_name, + '--url', url, + '--client-cert', '@client-cert-file', + '--client-key', '@client-key-file' + ) + end + + it 'passes ca_cert through temporary file arguments' do + provider = new_provider(url: url, ca_cert: ca_cert) + file_arguments = [ + ['--ca-cert', ca_cert] + ] + + stub_temp_file_arguments( + provider, + [ + '--ca-cert', '@ca-cert-file' + ] + ) + stub_pulp(provider) + + provider.create_resource + + expect(provider).to have_received(:with_temp_file_arguments).with(file_arguments) + expect(provider).to have_received(:pulp).with( + 'rpm', 'remote', 'create', + '--name', resource_name, + '--url', url, + '--ca-cert', '@ca-cert-file' + ) + end + + it 'does not pass absent client_cert or absent ca_cert through temporary file arguments' do + provider = new_provider( + url: url, + client_cert: :absent, + ca_cert: :absent + ) + + stub_temp_file_arguments(provider) + stub_pulp(provider) + + provider.create_resource + + expect(provider).to have_received(:with_temp_file_arguments).with([]) + expect(provider).to have_received(:pulp).with( + 'rpm', 'remote', 'create', + '--name', resource_name, + '--url', url + ) + end + end + + describe '#update_resource' do + it 'updates only flushed properties' do + provider = new_provider + + provider.url = updated_url + provider.policy = 'streamed' + provider.tls_validation = :true + + stub_temp_file_arguments(provider) + stub_pulp(provider) + + provider.update_resource + + expect(provider).to have_received(:with_temp_file_arguments).with([]) + expect(provider).to have_received(:pulp).with( + 'rpm', 'remote', 'update', + '--name', resource_name, + '--url', updated_url, + '--policy', 'streamed', + '--tls-validation', 'true' + ) + end + + it 'clears client certificate and key together when client_cert is absent' do + provider = new_provider + + provider.client_cert = :absent + + stub_temp_file_arguments(provider) + stub_pulp(provider) + + provider.update_resource + + expect(provider).to have_received(:with_temp_file_arguments).with([]) + expect(provider).to have_received(:pulp).with( + 'rpm', 'remote', 'update', + '--name', resource_name, + '--client-cert', '', + '--client-key', '' + ) + end + + it 'clears ca_cert when ca_cert is absent' do + provider = new_provider + + provider.ca_cert = :absent + + stub_temp_file_arguments(provider) + stub_pulp(provider) + + provider.update_resource + + expect(provider).to have_received(:with_temp_file_arguments).with([]) + expect(provider).to have_received(:pulp).with( + 'rpm', 'remote', 'update', + '--name', resource_name, + '--ca-cert', '' + ) + end + + it 'passes updated client certificate and key through temporary file arguments' do + provider = new_provider(client_key: client_key) + + provider.client_cert = client_cert + + file_arguments = [ + ['--client-cert', client_cert], + ['--client-key', client_key] + ] + + stub_temp_file_arguments( + provider, + [ + '--client-cert', '@client-cert-file', + '--client-key', '@client-key-file' + ] + ) + stub_pulp(provider) + + provider.update_resource + + expect(provider).to have_received(:with_temp_file_arguments).with(file_arguments) + expect(provider).to have_received(:pulp).with( + 'rpm', 'remote', 'update', + '--name', resource_name, + '--client-cert', '@client-cert-file', + '--client-key', '@client-key-file' + ) + end + + it 'passes updated ca_cert through temporary file arguments' do + provider = new_provider + + provider.ca_cert = ca_cert + + file_arguments = [ + ['--ca-cert', ca_cert] + ] + + stub_temp_file_arguments( + provider, + [ + '--ca-cert', '@ca-cert-file' + ] + ) + stub_pulp(provider) + + provider.update_resource + + expect(provider).to have_received(:with_temp_file_arguments).with(file_arguments) + expect(provider).to have_received(:pulp).with( + 'rpm', 'remote', 'update', + '--name', resource_name, + '--ca-cert', '@ca-cert-file' + ) + end + end + + describe '#delete_resource' do + it 'destroys the remote by name' do + provider = new_provider + + stub_pulp(provider) + + provider.delete_resource + + expect(provider).to have_received(:pulp).with( + 'rpm', 'remote', 'destroy', + '--name', resource_name + ) + end + end + + describe '#required_client_key' do + it 'returns the configured client_key' do + provider = new_provider(client_key: client_key) + + expect(provider.send(:required_client_key)).to eq(client_key) + end + + it 'raises when client_key is missing' do + provider = new_provider + + expect do + provider.send(:required_client_key) + end.to raise_error(Puppet::DevError, %r{client_key.*required}) + end + + it 'raises when client_key is absent' do + provider = new_provider(client_key: :absent) + + expect do + provider.send(:required_client_key) + end.to raise_error(Puppet::DevError, %r{client_key.*required}) + end + end +end diff --git a/spec/unit/puppet/provider/pulpcore_rpm_remote_spec.rb b/spec/unit/puppet/provider/pulpcore_rpm_remote_spec.rb new file mode 100644 index 00000000..e8ae4f58 --- /dev/null +++ b/spec/unit/puppet/provider/pulpcore_rpm_remote_spec.rb @@ -0,0 +1,214 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'puppet/provider/pulpcore_rpm_remote' + +describe Puppet::Provider::PulpcoreRpmRemote do + let(:resource_name) { 'test_remote' } + let(:url) { 'https://example.com/pulp/content/test/' } + let(:policy) { 'on_demand' } + let(:client_cert) { "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n" } + let(:ca_cert) { "-----BEGIN CERTIFICATE-----\nMIIC\n-----END CERTIFICATE-----\n" } + + let(:remote_api_hash) do + { + 'name' => resource_name, + 'url' => url, + 'policy' => policy, + 'tls_validation' => true, + 'client_cert' => client_cert, + 'ca_cert' => ca_cert, + 'hidden_fields' => [ + { 'name' => 'client_key', 'is_set' => true } + ] + } + end + + def new_provider(attributes = {}) + described_class.new({ name: resource_name, ensure: :present }.merge(attributes)) + end + + def property_flush(provider) + provider.instance_variable_get(:@property_flush) + end + + describe '.resource_properties_from_api_hash' do + it 'maps Pulp API fields to Puppet provider properties' do + expect(described_class.resource_properties_from_api_hash(remote_api_hash)).to eq( + name: resource_name, + ensure: :present, + provider: described_class.name, + url: url, + policy: policy, + tls_validation: :true, + client_cert: client_cert, + ca_cert: ca_cert, + client_key_set: true + ) + end + + it 'maps false tls_validation to :false' do + api_hash = remote_api_hash + api_hash['tls_validation'] = false + + expect(described_class.resource_properties_from_api_hash(api_hash)[:tls_validation]).to eq(:false) + end + + it 'maps nil client_cert to :absent' do + api_hash = remote_api_hash + api_hash['client_cert'] = nil + + expect(described_class.resource_properties_from_api_hash(api_hash)[:client_cert]).to eq(:absent) + end + + it 'maps nil ca_cert to :absent' do + api_hash = remote_api_hash + api_hash['ca_cert'] = nil + + expect(described_class.resource_properties_from_api_hash(api_hash)[:ca_cert]).to eq(:absent) + end + + it 'raises when hidden_fields is missing' do + api_hash = remote_api_hash + api_hash.delete('hidden_fields') + + expect do + described_class.resource_properties_from_api_hash(api_hash) + end.to raise_error(Puppet::Error, %r{did not include hidden_fields}) + end + + it 'raises when hidden_fields is nil' do + api_hash = remote_api_hash + api_hash['hidden_fields'] = nil + + expect do + described_class.resource_properties_from_api_hash(api_hash) + end.to raise_error(Puppet::Error, %r{hidden_fields was nil}) + end + + it 'raises when hidden_fields does not include client_key' do + api_hash = remote_api_hash + api_hash['hidden_fields'] = [ + { 'name' => 'proxy_password', 'is_set' => true } + ] + + expect do + described_class.resource_properties_from_api_hash(api_hash) + end.to raise_error(Puppet::Error, %r{did not include client_key in hidden_fields}) + end + end + + describe '.hidden_field_set?' do + it 'returns true when the named hidden field is set' do + api_hash = { + 'hidden_fields' => [ + { 'name' => 'client_key', 'is_set' => true } + ] + } + + expect(described_class.hidden_field_set?(api_hash, 'client_key')).to be(true) + end + + it 'returns false when the named hidden field is not set' do + api_hash = { + 'hidden_fields' => [ + { 'name' => 'client_key', 'is_set' => false } + ] + } + + expect(described_class.hidden_field_set?(api_hash, 'client_key')).to be(false) + end + + it 'raises when the named hidden field is absent' do + api_hash = { + 'hidden_fields' => [ + { 'name' => 'proxy_username', 'is_set' => true } + ] + } + + expect do + described_class.hidden_field_set?(api_hash, 'client_key') + end.to raise_error(Puppet::Error, %r{did not include client_key in hidden_fields}) + end + + it 'raises when hidden_fields is nil' do + expect do + described_class.hidden_field_set?({ 'hidden_fields' => nil }, 'client_key') + end.to raise_error(Puppet::Error, %r{hidden_fields was nil}) + end + + it 'raises when hidden_fields is missing' do + expect do + described_class.hidden_field_set?({}, 'client_key') + end.to raise_error(Puppet::Error, %r{did not include hidden_fields}) + end + end + + describe '#client_key_set?' do + it 'returns true when client_key_set is true' do + expect(new_provider(client_key_set: true).client_key_set?).to be(true) + end + + it 'returns false when client_key_set is false' do + expect(new_provider(client_key_set: false).client_key_set?).to be(false) + end + end + + describe 'property setters' do + it 'stores url changes in property_flush' do + provider = new_provider + + provider.url = url + + expect(property_flush(provider)).to eq(url: url) + end + + it 'stores policy changes in property_flush' do + provider = new_provider + + provider.policy = policy + + expect(property_flush(provider)).to eq(policy: policy) + end + + it 'stores tls_validation changes in property_flush' do + provider = new_provider + + provider.tls_validation = :false + + expect(property_flush(provider)).to eq(tls_validation: :false) + end + + it 'stores client_cert changes in property_flush' do + provider = new_provider + + provider.client_cert = client_cert + + expect(property_flush(provider)).to eq(client_cert: client_cert) + end + + it 'stores an empty string when client_cert is set to absent' do + provider = new_provider + + provider.client_cert = :absent + + expect(property_flush(provider)).to eq(client_cert: '') + end + + it 'stores ca_cert changes in property_flush' do + provider = new_provider + + provider.ca_cert = ca_cert + + expect(property_flush(provider)).to eq(ca_cert: ca_cert) + end + + it 'stores an empty string when ca_cert is set to absent' do + provider = new_provider + + provider.ca_cert = :absent + + expect(property_flush(provider)).to eq(ca_cert: '') + end + end +end diff --git a/spec/unit/puppet/provider/pulpcore_rpm_repo/cli_spec.rb b/spec/unit/puppet/provider/pulpcore_rpm_repo/cli_spec.rb new file mode 100644 index 00000000..1df83a55 --- /dev/null +++ b/spec/unit/puppet/provider/pulpcore_rpm_repo/cli_spec.rb @@ -0,0 +1,279 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'puppet/provider/pulpcore_rpm_repo/cli' + +describe Puppet::Type.type(:pulpcore_rpm_repo).provider(:cli) do + let(:resource_name) { 'test_repo' } + let(:remote_name) { 'test_remote' } + let(:description) { 'Test repository' } + + def new_resource(attributes = {}) + Puppet::Type.type(:pulpcore_rpm_repo).new( + { + name: resource_name, + provider: :cli + }.merge(attributes) + ) + end + + def new_provider(attributes = {}) + described_class.new(name: resource_name).tap do |provider| + provider.resource = new_resource(attributes) + end + end + + def stub_pulp(provider) + allow(provider).to receive(:pulp) + end + + describe '.resource_api_hashes' do + it 'lists RPM repositories through the Pulp CLI and parses the JSON response' do + api_hashes = [ + { 'name' => resource_name } + ] + + allow(described_class).to receive(:pulp).and_return(JSON.generate(api_hashes)) + + expect(described_class.resource_api_hashes).to eq(api_hashes) + expect(described_class).to have_received(:pulp).with( + 'rpm', + 'repository', + 'list', + '--limit', + 1_000_000 + ) + end + end + + describe '.resource_api_hash' do + it 'shows an RPM repository by name and parses the JSON response' do + api_hash = { 'name' => resource_name } + + allow(described_class).to receive(:pulp).and_return(JSON.generate(api_hash)) + + expect(described_class.resource_api_hash(resource_name)).to eq(api_hash) + expect(described_class).to have_received(:pulp).with( + 'rpm', + 'repository', + 'show', + '--name', + resource_name + ) + end + end + + describe '#create_resource' do + it 'creates a repository with the required arguments' do + provider = new_provider + + stub_pulp(provider) + + provider.create_resource + + expect(provider).to have_received(:pulp).with( + 'rpm', + 'repository', + 'create', + '--name', + resource_name + ) + end + + it 'includes optional properties when set' do + provider = new_provider( + description: description, + remote: remote_name, + retain_package_versions: '10', + retain_repo_versions: '3' + ) + + stub_pulp(provider) + + provider.create_resource + + expect(provider).to have_received(:pulp).with( + 'rpm', + 'repository', + 'create', + '--name', + resource_name, + '--description', + description, + '--remote', + remote_name, + '--retain-package-versions', + 10, + '--retain-repo-versions', + 3 + ) + end + + it 'does not include optional removable properties when they are absent' do + provider = new_provider( + description: :absent, + remote: :absent, + retain_repo_versions: :absent + ) + + stub_pulp(provider) + + provider.create_resource + + expect(provider).to have_received(:pulp).with( + 'rpm', + 'repository', + 'create', + '--name', + resource_name + ) + end + + it 'uses --autopublish when autopublish is true' do + provider = new_provider(autopublish: true) + + stub_pulp(provider) + + provider.create_resource + + expect(provider).to have_received(:pulp).with( + 'rpm', + 'repository', + 'create', + '--name', + resource_name, + '--autopublish' + ) + end + + it 'uses --no-autopublish when autopublish is false' do + provider = new_provider(autopublish: false) + + stub_pulp(provider) + + provider.create_resource + + expect(provider).to have_received(:pulp).with( + 'rpm', + 'repository', + 'create', + '--name', + resource_name, + '--no-autopublish' + ) + end + end + + describe '#update_resource' do + it 'updates only flushed properties' do + provider = new_provider + + provider.description = description + provider.remote = remote_name + provider.retain_package_versions = 10 + provider.retain_repo_versions = 3 + + stub_pulp(provider) + + provider.update_resource + + expect(provider).to have_received(:pulp).with( + 'rpm', + 'repository', + 'update', + '--name', + resource_name, + '--description', + description, + '--remote', + remote_name, + '--retain-package-versions', + 10, + '--retain-repo-versions', + 3 + ) + end + + it 'clears removable properties using empty strings' do + provider = new_provider + + provider.description = :absent + provider.remote = :absent + provider.retain_repo_versions = :absent + + stub_pulp(provider) + + provider.update_resource + + expect(provider).to have_received(:pulp).with( + 'rpm', + 'repository', + 'update', + '--name', + resource_name, + '--description', + '', + '--remote', + '', + '--retain-repo-versions', + '' + ) + end + + it 'uses --autopublish when autopublish is true' do + provider = new_provider + + provider.autopublish = :true + + stub_pulp(provider) + + provider.update_resource + + expect(provider).to have_received(:pulp).with( + 'rpm', + 'repository', + 'update', + '--name', + resource_name, + '--autopublish' + ) + end + + it 'uses --no-autopublish when autopublish is false' do + provider = new_provider + + provider.autopublish = :false + + stub_pulp(provider) + + provider.update_resource + + expect(provider).to have_received(:pulp).with( + 'rpm', + 'repository', + 'update', + '--name', + resource_name, + '--no-autopublish' + ) + end + end + + describe '#delete_resource' do + it 'destroys the repository by name' do + provider = new_provider + + stub_pulp(provider) + + provider.delete_resource + + expect(provider).to have_received(:pulp).with( + 'rpm', + 'repository', + 'destroy', + '--name', + resource_name + ) + end + end +end diff --git a/spec/unit/puppet/provider/pulpcore_rpm_repo_spec.rb b/spec/unit/puppet/provider/pulpcore_rpm_repo_spec.rb new file mode 100644 index 00000000..3e232a81 --- /dev/null +++ b/spec/unit/puppet/provider/pulpcore_rpm_repo_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'puppet/provider/pulpcore_rpm_repo' + +describe Puppet::Provider::PulpcoreRpmRepo do + let(:resource_name) { 'test_repo' } + let(:remote_name) { 'test_remote' } + let(:remote_href) { '/pulp/api/v3/remotes/rpm/rpm/test_remote/' } + let(:provider) { described_class.new(name: resource_name, ensure: :present) } + let(:description) { 'Test repository' } + + def property_flush(provider) + provider.instance_variable_get(:@property_flush) + end + + before do + # Reset the cache + described_class.instance_variable_set(:@name_by_href, {}) + end + + describe '.resource_properties_from_api_hash' do + let(:repo_api_hash) do + { + 'name' => resource_name, + 'description' => description, + 'remote' => remote_href, + 'retain_package_versions' => 10, + 'retain_repo_versions' => 3, + 'autopublish' => true + } + end + + it 'maps Pulp API fields to Puppet provider properties' do + allow(described_class).to receive(:api_hash_by_href).and_return('name' => remote_name) + + expect(described_class.resource_properties_from_api_hash(repo_api_hash)).to eq( + name: resource_name, + ensure: :present, + provider: described_class.name, + description: description, + remote: remote_name, + retain_package_versions: 10, + retain_repo_versions: 3, + autopublish: :true + ) + + expect(described_class).to have_received(:api_hash_by_href).with(remote_href) + end + + it 'maps nil removable fields to :absent' do + api_hash = repo_api_hash + api_hash['description'] = nil + api_hash['remote'] = nil + api_hash['retain_repo_versions'] = nil + + expect(described_class.resource_properties_from_api_hash(api_hash)).to include( + description: :absent, + remote: :absent, + retain_repo_versions: :absent + ) + end + + it 'maps false autopublish to :false' do + api_hash = repo_api_hash + api_hash['remote'] = nil + api_hash['autopublish'] = false + + expect(described_class.resource_properties_from_api_hash(api_hash)[:autopublish]).to eq(:false) + end + end + + describe 'property setters' do + it 'stores description changes in property_flush' do + provider.description = description + + expect(property_flush(provider)).to eq(description: description) + end + + it 'stores an empty string when description is set to absent' do + provider.description = :absent + + expect(property_flush(provider)).to eq(description: '') + end + + it 'stores remote changes in property_flush' do + provider.remote = remote_name + + expect(property_flush(provider)).to eq(remote: remote_name) + end + + it 'stores an empty string when remote is set to absent' do + provider.remote = :absent + + expect(property_flush(provider)).to eq(remote: '') + end + + it 'stores retain_repo_versions changes in property_flush' do + provider.retain_repo_versions = 3 + + expect(property_flush(provider)).to eq(retain_repo_versions: 3) + end + + it 'stores an empty string when retain_repo_versions is set to absent' do + provider.retain_repo_versions = :absent + + expect(property_flush(provider)).to eq(retain_repo_versions: '') + end + + it 'stores retain_package_versions changes in property_flush' do + provider.retain_package_versions = 10 + + expect(property_flush(provider)).to eq(retain_package_versions: 10) + end + + it 'stores autopublish changes in property_flush' do + provider.autopublish = :true + + expect(property_flush(provider)).to eq(autopublish: :true) + end + end +end diff --git a/spec/unit/puppet/provider/pulpcore_spec.rb b/spec/unit/puppet/provider/pulpcore_spec.rb new file mode 100644 index 00000000..7b3f6a32 --- /dev/null +++ b/spec/unit/puppet/provider/pulpcore_spec.rb @@ -0,0 +1,298 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'puppet/provider/pulpcore' + +# Define a tiny test-only type so Pulpcore#update_property_hash can read +# resource[:name] from a real Puppet resource. +Puppet::Type.newtype(:pulpcore_provider_test) do + newparam(:name, namevar: true) +end + +describe Puppet::Provider::Pulpcore do + let(:resource_name) { 'test_resource' } + let(:provider) { described_class.new(name: resource_name) } + + def test_resource(resource_name) + Puppet::Type.type(:pulpcore_provider_test).new(name: resource_name) + end + + def property_flush(provider) + provider.instance_variable_get(:@property_flush) + end + + def property_hash(provider) + provider.instance_variable_get(:@property_hash) + end + + describe '.resource_api_hashes' do + it 'raises Puppet::DevError' do + expect do + described_class.resource_api_hashes + end.to raise_error(Puppet::DevError, %r{must implement \.resource_api_hashes}) + end + end + + describe '.resource_api_hash' do + it 'raises Puppet::DevError' do + expect do + described_class.resource_api_hash(resource_name) + end.to raise_error(Puppet::DevError, %r{must implement \.resource_api_hash}) + end + end + + describe '.resource_properties_from_api_hash' do + it 'raises Puppet::DevError' do + expect do + described_class.resource_properties_from_api_hash({}) + end.to raise_error(Puppet::DevError, %r{must implement \.resource_properties_from_api_hash}) + end + end + + describe '.api_hash_by_href' do + it 'raises Puppet::DevError' do + expect do + described_class.api_hash_by_href('/pulp/api/v3/repositories/rpm/rpm/test/') + end.to raise_error(Puppet::DevError, %r{must implement \.api_hash_by_href}) + end + end + + describe '.name_by_href' do + let(:href) { '/pulp/api/v3/repositories/rpm/rpm/test/' } + let(:provider_class) do + Class.new(described_class) do + def self.api_hash_by_href(_href) + { 'name' => 'resolved_name' } + end + end + end + + it 'returns :absent when the href is nil' do + expect(provider_class.name_by_href(nil)).to eq(:absent) + end + + it 'resolves an href to the referenced resource name' do + expect(provider_class.name_by_href(href)).to eq('resolved_name') + end + + it 'memoises lookups by href' do + allow(provider_class).to receive(:api_hash_by_href).and_return('name' => 'resolved_name') + + expect(provider_class.name_by_href(href)).to eq('resolved_name') + expect(provider_class.name_by_href(href)).to eq('resolved_name') + + expect(provider_class).to have_received(:api_hash_by_href).once + end + end + + describe '#create_resource' do + it 'raises Puppet::DevError' do + expect do + provider.create_resource + end.to raise_error(Puppet::DevError, %r{must implement #create_resource}) + end + end + + describe '#update_resource' do + it 'raises Puppet::DevError' do + expect do + provider.update_resource + end.to raise_error(Puppet::DevError, %r{must implement #update_resource}) + end + end + + describe '#delete_resource' do + it 'raises Puppet::DevError' do + expect do + provider.delete_resource + end.to raise_error(Puppet::DevError, %r{must implement #delete_resource}) + end + end + + describe '.mk_property_hash_getters' do + let(:provider_class) do + Class.new(described_class) do + mk_property_hash_getters(:description) + end + end + + it 'returns :absent when the stored value is nil' do + provider = provider_class.new(description: nil) + + expect(provider.description).to eq(:absent) + end + + it 'returns the stored value when the current value is present' do + provider = provider_class.new(description: 'test description') + + expect(provider.description).to eq('test description') + end + end + + describe '.instances' do + let(:provider_class) do + Class.new(described_class) do + mk_property_hash_getters(:name) + + def self.resource_api_hashes + [ + { 'name' => 'one' }, + { 'name' => 'two' } + ] + end + + def self.resource_properties_from_api_hash(api_hash) + { + name: api_hash['name'], + ensure: :present + } + end + end + end + + it 'builds provider instances from API hashes' do + instances = provider_class.instances + + expect(instances.map(&:class)).to eq([provider_class, provider_class]) + expect(instances.map(&:name)).to eq(%w[one two]) + end + end + + describe '.resource_properties' do + context 'when the API hash is found' do + let(:provider_class) do + Class.new(described_class) do + def self.resource_api_hash(resource_name) + { + 'name' => resource_name, + 'description' => 'from api' + } + end + + def self.resource_properties_from_api_hash(api_hash) + { + name: api_hash['name'], + ensure: :present, + description: api_hash['description'] + } + end + end + end + + it 'maps one API hash' do + expect(provider_class.resource_properties(resource_name)).to eq( + name: resource_name, + ensure: :present, + description: 'from api' + ) + end + end + + context 'when resource_api_hash raises Puppet::ExecutionFailure' do + let(:provider_class) do + Class.new(described_class) do + def self.resource_api_hash(_resource_name) + raise Puppet::ExecutionFailure, 'not found' + end + end + end + + it 'returns an empty hash' do + allow(provider_class).to receive(:warning) + + expect(provider_class.resource_properties(resource_name)).to eq({}) + expect(provider_class).to have_received(:warning).with(%r{#resource_properties had an error}) + end + end + end + + describe '#flush' do + let(:provider_class) do + Class.new(described_class) do + mk_property_hash_getters(:description) + + def self.resource_properties(resource_name) + { + name: resource_name, + ensure: :present, + description: "refetched #{resource_name}" + } + end + + def description=(value) + @property_flush[:description] = value + end + + def create_resource; end + + def update_resource; end + + def delete_resource; end + end + end + + let(:provider) do + provider_class.new(name: resource_name, ensure: :present).tap do |provider| + provider.resource = test_resource(resource_name) + end + end + + it 'calls create_resource when ensure was set to present' do + allow(provider).to receive(:create_resource) + + provider.create + provider.flush + + expect(provider).to have_received(:create_resource) + expect(provider.description).to eq("refetched #{resource_name}") + end + + it 'calls update_resource for property-only changes' do + allow(provider).to receive(:update_resource) + + provider.description = 'changed' + provider.flush + + expect(provider).to have_received(:update_resource) + expect(provider.description).to eq("refetched #{resource_name}") + end + + it 'calls delete_resource when ensure was set to absent' do + allow(provider).to receive(:delete_resource) + + provider.destroy + provider.flush + + expect(provider).to have_received(:delete_resource) + expect(provider.exists?).to be(false) + end + + it 'clears property_flush after creating' do + allow(provider).to receive(:create_resource) + + provider.create + provider.flush + + expect(property_flush(provider)).to eq({}) + end + + it 'clears property_flush after updating' do + allow(provider).to receive(:update_resource) + + provider.description = 'changed' + provider.flush + + expect(property_flush(provider)).to eq({}) + end + + it 'clears property_flush after deleting' do + allow(provider).to receive(:delete_resource) + + provider.destroy + provider.flush + + expect(property_flush(provider)).to eq({}) + expect(property_hash(provider)).to eq({}) + end + end +end diff --git a/spec/unit/puppet/type/pulpcore_rpm_distribution_spec.rb b/spec/unit/puppet/type/pulpcore_rpm_distribution_spec.rb new file mode 100644 index 00000000..bc00d638 --- /dev/null +++ b/spec/unit/puppet/type/pulpcore_rpm_distribution_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Puppet::Type.type(:pulpcore_rpm_distribution) do + let(:resource_name) { 'test_distribution' } + let(:repo_name) { 'test_repo' } + + def new_resource(attributes = {}) + described_class.new({ name: resource_name }.merge(attributes)) + end + + describe 'namevar' do + it 'uses name as the namevar' do + expect(described_class.key_attributes).to eq([:name]) + end + end + + describe 'ensure' do + it 'is ensurable' do + expect(described_class.attrtype(:ensure)).to eq(:property) + end + + it 'accepts absent' do + expect(new_resource(ensure: :absent)[:ensure]).to eq(:absent) + end + end + + describe 'base_path' do + it 'accepts a non-empty base path' do + expect(new_resource(base_path: 'rpm/test')[:base_path]).to eq('rpm/test') + end + + it 'rejects an empty base path' do + expect do + new_resource(base_path: '') + end.to raise_error(Puppet::Error) + end + end + + describe 'repo' do + it 'accepts a repository name' do + expect(new_resource(repo: repo_name)[:repo]).to eq(repo_name) + end + + it 'accepts absent' do + expect(new_resource(repo: 'absent')[:repo]).to eq(:absent) + end + + it 'rejects an empty string' do + expect do + new_resource(repo: '') + end.to raise_error(Puppet::Error) + end + end + + describe 'checkpoint' do + { + true => :true, + :true => :true, + 'true' => :true, + 'yes' => :true, + false => :false, + :false => :false, + 'false' => :false, + 'no' => :false + }.each do |input, expected| + it "munges #{input.inspect} to #{expected.inspect}" do + expect(new_resource(checkpoint: input)[:checkpoint]).to eq(expected) + end + end + + it 'rejects invalid boolean values' do + expect do + new_resource(checkpoint: 'maybe') + end.to raise_error(%r{expected a boolean value}) + end + end + + describe 'autorequire' do + it 'autorequires the configured repository' do + catalog = Puppet::Resource::Catalog.new + repo = Puppet::Type.type(:pulpcore_rpm_repo).new(name: repo_name) + distribution = new_resource(repo: repo_name) + + catalog.add_resource(repo) + catalog.add_resource(distribution) + + relationships = distribution.autorequire + + expect(relationships.map(&:source)).to include(repo) + expect(relationships.map(&:target)).to include(distribution) + end + + it 'does not autorequire a repository when repo is absent' do + catalog = Puppet::Resource::Catalog.new + distribution = new_resource(repo: :absent) + + catalog.add_resource(distribution) + + expect(distribution.autorequire).to be_empty + end + + it 'does not autorequire a repository when repo is unset' do + catalog = Puppet::Resource::Catalog.new + distribution = new_resource + + catalog.add_resource(distribution) + + expect(distribution.autorequire).to be_empty + end + end +end diff --git a/spec/unit/puppet/type/pulpcore_rpm_remote_spec.rb b/spec/unit/puppet/type/pulpcore_rpm_remote_spec.rb new file mode 100644 index 00000000..c71ffe25 --- /dev/null +++ b/spec/unit/puppet/type/pulpcore_rpm_remote_spec.rb @@ -0,0 +1,217 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Puppet::Type.type(:pulpcore_rpm_remote) do + let(:valid_cert) do + <<~CERT + -----BEGIN CERTIFICATE----- + MIIB + -----END CERTIFICATE----- + CERT + end + + let(:valid_key) do + <<~KEY + -----BEGIN PRIVATE KEY----- + MIIB + -----END PRIVATE KEY----- + KEY + end + + let(:resource_name) { 'test_remote' } + + def new_resource(attributes = {}) + described_class.new({ name: resource_name }.merge(attributes)) + end + + def resource_with_provider(attributes = {}, client_key_set:) + resource = new_resource(attributes) + + provider = Object.new + provider.define_singleton_method(:client_key_set?) { client_key_set } + + allow(resource).to receive(:provider).and_return(provider) + + resource + end + + describe 'namevar' do + it 'uses name as the namevar' do + expect(described_class.key_attributes).to eq([:name]) + end + end + + describe 'ensure' do + it 'is ensurable' do + expect(described_class.attrtype(:ensure)).to eq(:property) + end + + it 'accepts absent' do + expect(new_resource(ensure: :absent)[:ensure]).to eq(:absent) + end + end + + describe 'url' do + ['http://example.com/repo', 'https://example.com/repo', 'uln://ol8_x86_64_baseos_latest'].each do |url| + it "accepts #{url}" do + expect(new_resource(url: url)[:url]).to eq(url) + end + end + + ['ftp://example.com/repo', 'example.com/repo', 'https://'].each do |url| + it "rejects #{url}" do + expect do + new_resource(url: url) + end.to raise_error(Puppet::Error) + end + end + end + + describe 'policy' do + %w[immediate on_demand streamed].each do |policy| + it "accepts #{policy}" do + expect(new_resource(policy: policy)[:policy]).to eq(policy) + end + end + + it 'rejects invalid policies' do + expect do + new_resource(policy: 'lazy') + end.to raise_error(Puppet::Error) + end + end + + describe 'tls_validation' do + { + true => :true, + :true => :true, + 'true' => :true, + 'yes' => :true, + false => :false, + :false => :false, + 'false' => :false, + 'no' => :false + }.each do |input, expected| + it "munges #{input.inspect} to #{expected.inspect}" do + expect(new_resource(tls_validation: input)[:tls_validation]).to eq(expected) + end + end + + it 'rejects invalid boolean values' do + expect do + new_resource(tls_validation: 'maybe') + end.to raise_error(%r{expected a boolean value}) + end + end + + describe 'client_cert' do + it 'accepts a PEM certificate when client_key is provided' do + resource = new_resource(client_cert: valid_cert, client_key: valid_key) + + expect(resource[:client_cert]).to eq(valid_cert) + end + + it 'accepts absent' do + expect(new_resource(client_cert: 'absent')[:client_cert]).to eq(:absent) + end + + it 'rejects invalid certificate content' do + expect do + new_resource(client_cert: 'not a certificate', client_key: valid_key) + end.to raise_error(Puppet::Error) + end + + it 'is in sync when the certificate matches and the hidden client key is set' do + resource = resource_with_provider( + { client_cert: valid_cert, client_key: valid_key }, + client_key_set: true + ) + + expect(resource.property(:client_cert).insync?(valid_cert)).to be(true) + end + + it 'is out of sync when the certificate matches but the hidden client key is not set' do + resource = resource_with_provider( + { client_cert: valid_cert, client_key: valid_key }, + client_key_set: false + ) + + expect(resource.property(:client_cert).insync?(valid_cert)).to be(false) + end + + it 'is in sync when absent and the hidden client key is not set' do + resource = resource_with_provider( + { client_cert: :absent }, + client_key_set: false + ) + + expect(resource.property(:client_cert).insync?(:absent)).to be(true) + end + + it 'is out of sync when absent but the hidden client key is still set' do + resource = resource_with_provider( + { client_cert: :absent }, + client_key_set: true + ) + + expect(resource.property(:client_cert).insync?(:absent)).to be(false) + end + end + + describe 'client_key' do + it 'accepts a PEM private key' do + expect(new_resource(client_key: valid_key)[:client_key]).to eq(valid_key) + end + + it 'accepts absent' do + expect(new_resource(client_key: 'absent')[:client_key]).to eq(:absent) + end + + it 'rejects invalid private key content' do + expect do + new_resource(client_key: 'not a private key') + end.to raise_error(Puppet::Error) + end + + it 'is required when client_cert is set' do + expect do + new_resource(client_cert: valid_cert) + end.to raise_error(Puppet::Error, %r{client_key.*required}) + end + + it 'treats client_key => absent as missing when client_cert is set' do + expect do + new_resource(client_cert: valid_cert, client_key: :absent) + end.to raise_error(Puppet::Error, %r{client_key.*required}) + end + + it 'is not required when client_cert is absent' do + expect(new_resource(client_cert: :absent)[:client_cert]).to eq(:absent) + end + + it 'marks client_key as sensitive when present' do + resource = new_resource(client_key: valid_key) + + resource.send(:set_sensitive_parameters, []) + + expect(resource.parameter(:client_key).sensitive).to be(true) + end + end + + describe 'ca_cert' do + it 'accepts a PEM certificate' do + expect(new_resource(ca_cert: valid_cert)[:ca_cert]).to eq(valid_cert) + end + + it 'accepts absent' do + expect(new_resource(ca_cert: 'absent')[:ca_cert]).to eq(:absent) + end + + it 'rejects invalid certificate content' do + expect do + new_resource(ca_cert: 'not a certificate') + end.to raise_error(Puppet::Error) + end + end +end diff --git a/spec/unit/puppet/type/pulpcore_rpm_repo_spec.rb b/spec/unit/puppet/type/pulpcore_rpm_repo_spec.rb new file mode 100644 index 00000000..3d797b21 --- /dev/null +++ b/spec/unit/puppet/type/pulpcore_rpm_repo_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Puppet::Type.type(:pulpcore_rpm_repo) do + let(:resource_name) { 'test_repo' } + let(:remote_name) { 'test_remote' } + + def new_resource(attributes = {}) + described_class.new({ name: resource_name }.merge(attributes)) + end + + describe 'namevar' do + it 'uses name as the namevar' do + expect(described_class.key_attributes).to eq([:name]) + end + end + + describe 'ensure' do + it 'is ensurable' do + expect(described_class.attrtype(:ensure)).to eq(:property) + end + + it 'accepts absent' do + expect(new_resource(ensure: :absent)[:ensure]).to eq(:absent) + end + end + + describe 'description' do + it 'accepts a description' do + expect(new_resource(description: 'Test repository')[:description]).to eq('Test repository') + end + + it 'accepts absent' do + expect(new_resource(description: 'absent')[:description]).to eq(:absent) + end + + it 'rejects an empty string' do + expect do + new_resource(description: '') + end.to raise_error(Puppet::Error) + end + end + + describe 'remote' do + it 'accepts a remote name' do + expect(new_resource(remote: remote_name)[:remote]).to eq(remote_name) + end + + it 'accepts absent' do + expect(new_resource(remote: 'absent')[:remote]).to eq(:absent) + end + + it 'rejects an empty string' do + expect do + new_resource(remote: '') + end.to raise_error(Puppet::Error) + end + end + + describe 'retain_package_versions' do + it 'munges numeric strings to integers' do + expect(new_resource(retain_package_versions: '10')[:retain_package_versions]).to eq(10) + end + + it 'accepts zero' do + expect(new_resource(retain_package_versions: '0')[:retain_package_versions]).to eq(0) + end + + it 'rejects non-numeric values' do + expect do + new_resource(retain_package_versions: 'many') + end.to raise_error(Puppet::Error) + end + + it 'rejects negative values' do + expect do + new_resource(retain_package_versions: '-1') + end.to raise_error(Puppet::Error) + end + end + + describe 'retain_repo_versions' do + it 'munges numeric strings to integers' do + expect(new_resource(retain_repo_versions: '3')[:retain_repo_versions]).to eq(3) + end + + it 'accepts zero' do + expect(new_resource(retain_repo_versions: '0')[:retain_repo_versions]).to eq(0) + end + + it 'accepts absent' do + expect(new_resource(retain_repo_versions: 'absent')[:retain_repo_versions]).to eq(:absent) + end + + it 'rejects non-numeric values' do + expect do + new_resource(retain_repo_versions: 'many') + end.to raise_error(Puppet::Error) + end + + it 'rejects negative values' do + expect do + new_resource(retain_repo_versions: '-1') + end.to raise_error(Puppet::Error) + end + end + + describe 'autopublish' do + { + true => :true, + :true => :true, + 'true' => :true, + 'yes' => :true, + false => :false, + :false => :false, + 'false' => :false, + 'no' => :false + }.each do |input, expected| + it "munges #{input.inspect} to #{expected.inspect}" do + expect(new_resource(autopublish: input)[:autopublish]).to eq(expected) + end + end + + it 'rejects invalid boolean values' do + expect do + new_resource(autopublish: 'maybe') + end.to raise_error(%r{expected a boolean value}) + end + end + + describe 'autorequire' do + it 'autorequires the configured remote' do + catalog = Puppet::Resource::Catalog.new + remote = Puppet::Type.type(:pulpcore_rpm_remote).new(name: remote_name) + repo = new_resource(remote: remote_name) + + catalog.add_resource(remote) + catalog.add_resource(repo) + + relationships = repo.autorequire + + expect(relationships.map(&:source)).to include(remote) + expect(relationships.map(&:target)).to include(repo) + end + + it 'does not autorequire a remote when remote is absent' do + catalog = Puppet::Resource::Catalog.new + repo = new_resource(remote: :absent) + + catalog.add_resource(repo) + + expect(repo.autorequire).to be_empty + end + + it 'does not autorequire a remote when remote is unset' do + catalog = Puppet::Resource::Catalog.new + repo = new_resource + + catalog.add_resource(repo) + + expect(repo.autorequire).to be_empty + end + end +end