diff --git a/lib/pdk/cli/exec.rb b/lib/pdk/cli/exec.rb index 927a3e7d5..96adcb1b4 100644 --- a/lib/pdk/cli/exec.rb +++ b/lib/pdk/cli/exec.rb @@ -110,6 +110,10 @@ def add_spinner(message, opts = {}) @spinner = TTY::Spinner.new("[:spinner] #{message}", opts.merge(PDK::CLI::Util.spinner_opts_for_platform)) end + def update_environment(additional_env) + @environment.merge!(additional_env) + end + def execute! # Start spinning if configured. @spinner.auto_spin if @spinner @@ -193,12 +197,18 @@ def run_process_in_clean_env! def run_process! command_string = argv.join(' ') + PDK.logger.debug(_("Executing '%{command}'") % { command: command_string }) + if context == :module - PDK.logger.debug(_("Command environment: GEM_HOME is '%{gem_home}' and GEM_PATH is '%{gem_path}'") % { gem_home: @process.environment['GEM_HOME'], - gem_path: @process.environment['GEM_PATH'] }) + PDK.logger.debug(_('Command environment:')) + @process.environment.each do |var, val| + PDK.logger.debug(" #{var}: #{val}") + end end + start_time = Time.now + begin @process.start rescue ChildProcess::LaunchError => e @@ -215,7 +225,9 @@ def run_process! # Wait indfinitely if no timeout set. @process.wait end + @duration = Time.now - start_time + PDK.logger.debug(_("Execution of '%{command}' complete (duration: %{duration_in_seconds}s; exit code: %{exit_code})") % { command: command_string, duration_in_seconds: @duration, exit_code: @process.exit_code }) end diff --git a/lib/pdk/util/bundler.rb b/lib/pdk/util/bundler.rb index b2bdc2835..dfc4458eb 100644 --- a/lib/pdk/util/bundler.rb +++ b/lib/pdk/util/bundler.rb @@ -1,4 +1,5 @@ require 'bundler' +require 'digest' require 'fileutils' require 'pdk/util' require 'pdk/cli/exec' @@ -8,11 +9,11 @@ module Util module Bundler class BundleHelper; end - def self.ensure_bundle! + def self.ensure_bundle!(gem_overrides = {}) bundle = BundleHelper.new - if already_bundled?(bundle.gemfile) - PDK.logger.debug(_('Bundle has already been installed. Skipping run.')) + if already_bundled?(bundle.gemfile, gem_overrides) + PDK.logger.debug(_('Bundler managed gems already up to date.')) return end @@ -21,50 +22,50 @@ def self.ensure_bundle! return end - unless bundle.locked? - if PDK::Util.package_install? - # In packaged installs, try to use vendored Gemfile.lock as a starting point. - # The 'bundle install' below will pick up any new dependencies. - vendored_gemfile_lock = File.join(PDK::Util.package_cachedir, 'Gemfile.lock') - - if File.exist?(vendored_gemfile_lock) - PDK.logger.debug(_("No Gemfile.lock found in module. Using vendored Gemfile.lock from '%{source}'.") % { source: vendored_gemfile_lock }) - FileUtils.cp(vendored_gemfile_lock, File.join(PDK::Util.module_root, 'Gemfile.lock')) - end - else - # In non-packaged installs, just let bundler resolve deps as normal. - unless bundle.lock! - raise PDK::CLI::FatalError, _('Unable to resolve Gemfile dependencies.') - end - end + # Generate initial Gemfile.lock + if bundle.locked? + # Update puppet-related gem dependencies by re-resolving them specifically. + # If this is a packaged install, only consider already available gems at this point. + bundle.update_lock!(gem_overrides, local: PDK::Util.package_install?) + else + bundle.lock!(gem_overrides) end - unless bundle.installed? - unless bundle.install! - raise PDK::CLI::FatalError, _('Unable to install missing Gemfile dependencies.') - end + # Check for any still-unresolved dependencies. For packaged installs, this should + # only evaluate to false if the user has added custom gems that we don't vendor, in + # which case `bundle install` will resolve new dependencies as needed. + unless bundle.installed?(gem_overrides) + bundle.install!(gem_overrides) end - mark_as_bundled!(bundle.gemfile) + mark_as_bundled!(bundle.gemfile, gem_overrides) end - def self.already_bundled?(gemfile) - !(@bundled ||= {})[gemfile].nil? + def self.ensure_binstubs!(*gems) + bundle = BundleHelper.new + + bundle.binstubs!(gems) end - def self.mark_as_bundled!(gemfile) - (@bundled ||= {})[gemfile] = true + def self.already_bundled?(gemfile, gem_overrides) + !(@bundled ||= {})[bundle_cache_key(gemfile, gem_overrides)].nil? end - def self.ensure_binstubs!(*gems) - bundle = BundleHelper.new + def self.mark_as_bundled!(gemfile, gem_overrides) + (@bundled ||= {})[bundle_cache_key(gemfile, gem_overrides)] = true + end - unless bundle.binstubs!(gems) # rubocop:disable Style/GuardClause - raise PDK::CLI::FatalError, _('Unable to install requested binstubs.') - end + def self.bundle_cache_key(gemfile, gem_overrides) + override_sig = (gem_overrides || {}).sort_by { |gem, _| gem.to_s }.to_s + Digest::MD5.hexdigest(gemfile.to_s + override_sig) end + private_class_method :bundle_cache_key class BundleHelper + def gemfile + @gemfile ||= PDK::Util.find_upwards('Gemfile') + end + def gemfile? !gemfile.nil? end @@ -73,13 +74,17 @@ def locked? !gemfile_lock.nil? end - def installed? + def installed?(gem_overrides = {}) PDK.logger.debug(_('Checking for missing Gemfile dependencies.')) argv = ['check', "--gemfile=#{gemfile}"] argv << "--path=#{bundle_cachedir}" unless PDK::Util.package_install? - result = bundle_command(*argv).execute! + cmd = bundle_command(*argv).tap do |c| + c.update_environment(gemfile_env(gem_overrides)) unless gem_overrides.empty? + end + + result = cmd.execute! unless result[:exit_code].zero? PDK.logger.debug(result.values_at(:stdout, :stderr).join("\n")) @@ -88,69 +93,102 @@ def installed? result[:exit_code].zero? end - def lock! - spinner = TTY::Spinner.new("[:spinner] #{_('Resolving Gemfile dependencies.')}", PDK::CLI::Util.spinner_opts_for_platform) - spinner.auto_spin + def lock!(gem_overrides = {}) + if PDK::Util.package_install? + # In packaged installs, use vendored Gemfile.lock as a starting point. + # Subsequent 'bundle install' will still pick up any new dependencies. + vendored_gemfile_lock = File.join(PDK::Util.package_cachedir, 'Gemfile.lock') + + unless File.exist?(vendored_gemfile_lock) + raise PDK::CLI::FatalError, _('Vendored Gemfile.lock (%{source}) not found.') % { + source: vendored_gemfile_lock, + } + end - # After initial lockfile generation, re-resolve json gem to built-in - # version to avoid unncessary native compilation attempts. - lock_commands = [ - bundle_command('lock'), - bundle_command('lock', '--update=json', '--local'), - ] + PDK.logger.debug(_('Using vendored Gemfile.lock from %{source}.') % { source: vendored_gemfile_lock }) + FileUtils.cp(vendored_gemfile_lock, File.join(PDK::Util.module_root, 'Gemfile.lock')) + + # Update the vendored lock with any overrides + update_lock!(gem_overrides, local: true) unless gem_overrides.empty? + else + argv = ['lock'] + + cmd = bundle_command(*argv).tap do |c| + c.add_spinner(_('Resolving Gemfile dependencies.')) + c.update_environment(gemfile_env(gem_overrides)) unless gem_overrides.empty? + end - results = lock_commands.collect do |cmd| result = cmd.execute! unless result[:exit_code].zero? - spinner.error PDK.logger.fatal(result.values_at(:stdout, :stderr).join("\n")) - break [result] + raise PDK::CLI::FatalError, _('Unable to resolve Gemfile dependencies.') end + end + + # After initial lockfile generation, re-resolve json gem to built-in + # version to avoid unncessary native compilation attempts. + update_lock!({ json: nil }, local: true) + + true + end - result + def update_lock!(gem_overrides, options = {}) + return true if gem_overrides.empty? + + PDK.logger.debug(_('Updating Gemfile dependencies.')) + + update_gems = gem_overrides.keys.join(' ') + + argv = ['lock', "--update=#{update_gems}"] + argv << '--local' if options && options[:local] + + cmd = bundle_command(*argv).tap do |c| + c.update_environment(gemfile_env(gem_overrides)) end - return false unless results.all? { |result| result[:exit_code].zero? } + result = cmd.execute! + + unless result[:exit_code].zero? + PDK.logger.fatal(result.values_at(:stdout, :stderr).join("\n")) + raise PDK::CLI::FatalError, _('Unable to resolve Gemfile dependencies.') + end - spinner.success true end - def install! + def install!(gem_overrides = {}) argv = ['install', "--gemfile=#{gemfile}", '-j4'] argv << "--path=#{bundle_cachedir}" unless PDK::Util.package_install? - command = bundle_command(*argv).tap do |c| + cmd = bundle_command(*argv).tap do |c| c.add_spinner(_('Installing missing Gemfile dependencies.')) + c.update_environment(gemfile_env(gem_overrides)) unless gem_overrides.empty? end - result = command.execute! + result = cmd.execute! unless result[:exit_code].zero? PDK.logger.fatal(result.values_at(:stdout, :stderr).join("\n")) + raise PDK::CLI::FatalError, _('Unable to install missing Gemfile dependencies.') end - result[:exit_code].zero? + true end def binstubs!(gems) binstub_dir = File.join(File.dirname(gemfile), 'bin') return true if gems.all? { |gem| File.file?(File.join(binstub_dir, gem)) } - command = bundle_command('binstubs', *gems, '--force') - - result = command.execute! + cmd = bundle_command('binstubs', *gems, '--force') + result = cmd.execute! unless result[:exit_code].zero? PDK.logger.fatal(_("Failed to generate binstubs for '%{gems}':\n%{output}") % { gems: gems.join(' '), output: result.values_at(:stdout, :stderr).join("\n") }) + raise PDK::CLI::FatalError, _('Unable to install requested binstubs.') end - result[:exit_code].zero? - end - - def gemfile - @gemfile ||= PDK::Util.find_upwards('Gemfile') + true end private @@ -168,6 +206,20 @@ def gemfile_lock def bundle_cachedir @bundle_cachedir ||= PDK::Util.package_install? ? PDK::Util.package_cachedir : File.join(PDK::Util.cachedir) end + + def gemfile_env(gem_overrides) + gemfile_env = {} + + return gemfile_env unless gem_overrides.respond_to?(:each) + + gem_overrides.each do |gem, version| + gemfile_env['PUPPET_GEM_VERSION'] = version if gem.respond_to?(:to_s) && gem.to_s == 'puppet' + gemfile_env['FACTER_GEM_VERSION'] = version if gem.respond_to?(:to_s) && gem.to_s == 'facter' + gemfile_env['HIERA_GEM_VERSION'] = version if gem.respond_to?(:to_s) && gem.to_s == 'hiera' + end + + gemfile_env + end end end end diff --git a/lib/pdk/util/ruby_version.rb b/lib/pdk/util/ruby_version.rb index 315346703..34b4b73f0 100644 --- a/lib/pdk/util/ruby_version.rb +++ b/lib/pdk/util/ruby_version.rb @@ -45,6 +45,7 @@ def scan_for_packaged_rubies end def default_ruby_version + # TODO: may not be a safe assumption that highest available version should be default versions.keys.sort { |a, b| Gem::Version.new(b) <=> Gem::Version.new(a) }.first end @@ -66,11 +67,15 @@ def initialize(ruby_version = nil) def gem_path if PDK::Util.package_install? # Subprocesses use their own set of gems which are managed by pdk or - # installed with the package. - File.join(PDK::Util.package_cachedir, 'ruby', versions[ruby_version]) + # installed with the package. We also include the separate gem path + # where our packaged multi-puppet installations live. + [ + File.join(PDK::Util.package_cachedir, 'ruby', versions[ruby_version]), + File.join(PDK::Util.pdk_package_basedir, 'private', 'puppet', 'ruby', versions[ruby_version]), + ].join(File::PATH_SEPARATOR) else # This allows the subprocess to find the 'bundler' gem, which isn't - # in the cachedir above for gem installs. + # in GEM_HOME for gem installs. # TODO: There must be a better way to do this than shelling out to # gem... File.absolute_path(File.join(`gem which bundler`, '..', '..', '..', '..')) @@ -91,10 +96,21 @@ def gem_home def available_puppet_versions return @available_puppet_versions unless @available_puppet_versions.nil? - puppet_spec_files = Dir[File.join(gem_path, 'specifications', '**', 'puppet*.gemspec')] - puppet_spec_files += Dir[File.join(gem_home, 'specifications', '**', 'puppet*.gemspec')] - puppet_specs = puppet_spec_files.map { |r| Gem::Specification.load(r) } - @available_puppet_versions = puppet_specs.select { |r| r.name == 'puppet' }.map { |r| r.version }.sort { |a, b| b <=> a } + + puppet_spec_files = Dir[File.join(gem_home, 'specifications', '**', 'puppet*.gemspec')] + + gem_path.split(File::PATH_SEPARATOR).each do |path| + puppet_spec_files += Dir[File.join(path, 'specifications', '**', 'puppet*.gemspec')] + end + + puppet_specs = [] + + puppet_spec_files.each do |specfile| + spec = Gem::Specification.load(specfile) + puppet_specs << spec if spec.name == 'puppet' + end + + @available_puppet_versions = puppet_specs.map(&:version).sort { |a, b| b <=> a } end private diff --git a/lib/pdk/util/version.rb b/lib/pdk/util/version.rb index 78b2ef461..80bc3799b 100644 --- a/lib/pdk/util/version.rb +++ b/lib/pdk/util/version.rb @@ -1,6 +1,7 @@ require 'pdk/version' require 'pdk/cli/exec' require 'pdk/util/git' +require 'pdk/logger' module PDK module Util @@ -33,6 +34,7 @@ def self.git_ref end def self.version_file + # FIXME: this gets called a LOT and doesn't currently get cached PDK::Util.find_upwards('PDK_VERSION', File.dirname(__FILE__)) end end diff --git a/spec/support/packaged_install.rb b/spec/support/packaged_install.rb new file mode 100644 index 000000000..22ae20a1a --- /dev/null +++ b/spec/support/packaged_install.rb @@ -0,0 +1,16 @@ +RSpec.shared_context 'packaged install' do + let(:package_cachedir) { '/package/share/cache' } + + before(:each) do + allow(PDK::Util).to receive(:package_install?).and_return(true) + allow(File).to receive(:file?).with(%r{PDK_VERSION}).and_return(true) + allow(File).to receive(:exist?).with(%r{bundle(\.bat)?$}).and_return(true) + allow(PDK::Util).to receive(:package_cachedir).and_return(package_cachedir) + end +end + +RSpec.shared_context 'not packaged install' do + before(:each) do + allow(PDK::Util).to receive(:package_install?).and_return(false) + end +end diff --git a/spec/unit/pdk/util/bundler_spec.rb b/spec/unit/pdk/util/bundler_spec.rb index 00b3abd57..a6c068ef8 100644 --- a/spec/unit/pdk/util/bundler_spec.rb +++ b/spec/unit/pdk/util/bundler_spec.rb @@ -2,230 +2,655 @@ require 'pdk/util/bundler' RSpec.describe PDK::Util::Bundler do - before(:each) do - # Doesn't matter where this is since all the execs get mocked. - allow(PDK::Util).to receive(:module_root).and_return('/') + describe 'class methods' do + # Default to non-package install + include_context 'not packaged install' - # Don't trigger the package-based install stuff. - allow(PDK::Util).to receive(:package_install?).and_return(false) - end + let(:bundle_helper) do + instance_double(PDK::Util::Bundler::BundleHelper, gemfile: '/Gemfile', gemfile?: true) + end - # @todo: untangle tests of PDK::Util::Bundler and - # PDK::Util::Bundler::BundleHelper + before(:each) do + # Allow us to mock/stub/expect calls to the internal bundle helper. + allow(PDK::Util::Bundler::BundleHelper).to receive(:new).and_return(bundle_helper) + end - # TODO: deduplicate code in these two methods and extract them to a shared location - def allow_command(argv, result = nil) - result ||= { exit_code: 0, stdout: '', stderr: '' } + describe '.ensure_bundle!' do + before(:each) do + # Avoid the early short-circuit this method implements unless we are + # explicitly unit testing that method. + allow(described_class).to receive(:already_bundled?).and_return(false) + end - command_double = instance_double(PDK::CLI::Exec::Command, 'context=' => true, 'execute!' => result, 'add_spinner' => true) + context 'when there is no Gemfile' do + before(:each) do + allow(bundle_helper).to receive(:gemfile?).and_return(false) + end - allow(PDK::CLI::Exec::Command).to receive(:new).with(*argv).and_return(command_double) - end + it 'does nothing' do + expect(bundle_helper).not_to receive(:locked?) + expect(bundle_helper).not_to receive(:installed?) + expect(bundle_helper).not_to receive(:lock!) + expect(bundle_helper).not_to receive(:update_lock!) + expect(bundle_helper).not_to receive(:install!) - def expect_command(argv, result = nil, spinner_message = nil) - result ||= { exit_code: 0, stdout: '', stderr: '' } + described_class.ensure_bundle! + end + end - command_double = instance_double(PDK::CLI::Exec::Command, 'context=' => true, 'execute!' => result) + context 'when given Gemfile has already been bundled' do + before(:each) do + allow(described_class).to receive(:already_bundled?).and_return(true) + end - if spinner_message - expect(command_double).to receive(:add_spinner).with(spinner_message, any_args) - end + it 'does nothing' do + expect(bundle_helper).not_to receive(:locked?) + expect(bundle_helper).not_to receive(:installed?) + expect(bundle_helper).not_to receive(:lock!) + expect(bundle_helper).not_to receive(:update_lock!) + expect(bundle_helper).not_to receive(:install!) - expect(PDK::CLI::Exec::Command).to receive(:new).with(*argv).and_return(command_double) - end + described_class.ensure_bundle! + end + end - def bundle_regex - %r{bundle(\.bat)?$} - end + context 'when there is an existing Gemfile.lock' do + before(:each) do + allow(bundle_helper).to receive(:locked?).and_return(true) + allow(bundle_helper).to receive(:installed?).and_return(true) + end - describe '.ensure_bundle!' do - context 'when there is no Gemfile' do - before(:each) do - allow(File).to receive(:file?).with(%r{Gemfile$}).and_return(false) + it 'updates Gemfile.lock using default sources' do + expect(bundle_helper).to receive(:update_lock!).with(anything, hash_including(local: false)) + + described_class.ensure_bundle! + end + + context 'when part of a packaged installation' do + include_context 'packaged install' + + it 'updates Gemfile.lock using local gems' do + expect(bundle_helper).to receive(:update_lock!).with(anything, hash_including(local: true)) + + described_class.ensure_bundle! + end + end end - it 'does nothing' do - expect(PDK::CLI::Exec::Command).not_to receive(:new) + context 'when there is no Gemfile.lock' do + before(:each) do + allow(bundle_helper).to receive(:locked?).and_return(false) + allow(bundle_helper).to receive(:installed?).and_return(true) + end - described_class.ensure_bundle! + it 'generates Gemfile.lock' do + expect(bundle_helper).to receive(:lock!) + + described_class.ensure_bundle! + end end - end - context 'when there is no Gemfile.lock' do - before(:each) do - mock_spinner = instance_double(TTY::Spinner, 'auto_spin' => true, 'success' => true, 'error' => true) - allow(TTY::Spinner).to receive(:new).and_return(mock_spinner) + context 'when there are missing gems' do + before(:each) do + allow(bundle_helper).to receive(:locked?).and_return(true) + allow(bundle_helper).to receive(:update_lock!) - allow(described_class).to receive(:already_bundled?).and_return(false) + allow(bundle_helper).to receive(:installed?).and_return(false) + end - allow(File).to receive(:file?).with(%r{Gemfile$}).and_return(true) - allow(File).to receive(:file?).with(%r{Gemfile\.lock$}).and_return(false) + it 'installs missing gems' do + expect(bundle_helper).to receive(:install!) - allow_command([bundle_regex, 'check', any_args], exit_code: 1, stdout: 'check stdout', stderr: 'check stderr') - allow($stderr).to receive(:puts).with('check stdout') - allow($stderr).to receive(:puts).with('check stderr') - allow_command([bundle_regex, 'install', any_args]) + described_class.ensure_bundle! + end end - context 'when part of a packaged installation' do + context 'when there are no missing gems' do before(:each) do - allow(PDK::Util).to receive(:package_install?).and_return(true) - allow(File).to receive(:file?).with(%r{PDK_VERSION}).and_return(true) - allow(File).to receive(:exist?).with(bundle_regex).and_return(true) + allow(bundle_helper).to receive(:locked?).and_return(true) + allow(bundle_helper).to receive(:update_lock!) + + allow(bundle_helper).to receive(:installed?).and_return(true) end - it 'copies a Gemfile.lock from vendored location' do - allow(PDK::Util).to receive(:package_cachedir).and_return('/package/cachedir') - allow(File).to receive(:exist?).with('/package/cachedir/Gemfile.lock').and_return(true) + it 'does not try to install missing gems' do + expect(bundle_helper).not_to receive(:install!) - expect(logger).to receive(:debug).with(%r{using vendored gemfile\.lock}i) - expect(FileUtils).to receive(:cp).with('/package/cachedir/Gemfile.lock', %r{Gemfile\.lock$}) + described_class.ensure_bundle! + end + end + + context 'when everything goes well' do + before(:each) do + allow(bundle_helper).to receive(:locked?).and_return(true) + allow(bundle_helper).to receive(:update_lock!) + allow(bundle_helper).to receive(:installed?).and_return(true) + end + + it 'marks gemfile as bundled' do + expect(described_class).to receive(:mark_as_bundled!).with(bundle_helper.gemfile, anything) described_class.ensure_bundle! end + + context 'when overriding gems' do + let(:overrides) do + { puppet: nil } + end + + it 'marks gemfile/overrides combo as bundled' do + expect(described_class).to receive(:mark_as_bundled!).with(bundle_helper.gemfile, overrides) + + described_class.ensure_bundle!(overrides) + end + end + end + end + + describe '.ensure_binstubs!' do + let(:gems) { %w[apple banana mango] } + + it 'delegates to BundleHelper.binstubs!' do + expect(bundle_helper).to receive(:binstubs!).with(gems) + + described_class.ensure_binstubs!(*gems) + end + end + + describe '.already_bundled?' do + let(:bundled_gemfile) { '/already/bundled/Gemfile' } + let(:unbundled_gemfile) { '/to/be/bundled/Gemfile' } + let(:overrides) { {} } + + before(:each) do + described_class.mark_as_bundled!(bundled_gemfile, overrides) end - it 'generates Gemfile.lock' do - expect_command([bundle_regex, 'lock'], nil) - expect_command([bundle_regex, 'lock', '--update=json', '--local'], nil) + it 'returns false for unbundled Gemfile' do + expect(described_class.already_bundled?(unbundled_gemfile, overrides)).to be false + end - described_class.ensure_bundle! + it 'returns true for already bundled Gemfile' do + expect(described_class.already_bundled?(bundled_gemfile, overrides)).to be true end - context 'and it fails to generate Gemfile.lock' do + context 'with gem overrides' do + let(:overrides) do + { gem1: '1.2.3', gem2: '4.5.6' } + end + before(:each) do - allow(described_class).to receive(:already_bundled?).and_return(false) - allow_command([bundle_regex, 'lock', any_args], exit_code: 1, stdout: 'lock stdout', stderr: 'lock stderr') - allow($stderr).to receive(:puts).with('lock stdout') - allow($stderr).to receive(:puts).with('lock stderr') + described_class.mark_as_bundled!(bundled_gemfile, overrides) + end + + it 'returns false for unbundled Gemfile' do + expect(described_class.already_bundled?(unbundled_gemfile, overrides)).to be false + end + + it 'returns false for already bundled Gemfile with additional overrides' do + expect(described_class.already_bundled?(unbundled_gemfile, overrides.merge(gem3: '2.2.2'))).to be false + end + + it 'returns true for already bundled Gemfile' do + expect(described_class.already_bundled?(bundled_gemfile, overrides)).to be true + end + end + end + + describe '.mark_as_bundled!' do + let(:gemfile) { '/newly/bundled/Gemfile' } + let(:overrides) { {} } + + it 'changes response of already_bundled? from false to true' do + expect { + described_class.mark_as_bundled!(gemfile, overrides) + }.to change { + described_class.already_bundled?(gemfile, overrides) + }.from(false).to(true) + end + + context 'with gem overrides' do + let(:overrides) do + { gem1: '1.2.3', gem2: '4.5.6' } end - it 'raises a FatalError' do + it 'changes response of already_bundled? from false to true' do expect { - described_class.ensure_bundle! - }.to raise_error(PDK::CLI::FatalError, %r{unable to resolve gemfile dependencies}i) + described_class.mark_as_bundled!(gemfile, overrides) + }.to change { + described_class.already_bundled?(gemfile, overrides) + }.from(false).to(true) end end end + end - context 'when there are missing gems' do - before(:each) do - allow(File).to receive(:file?).with(%r{Gemfile$}).and_return(true) - allow(File).to receive(:file?).with(%r{Gemfile\.lock$}).and_return(true) + describe PDK::Util::Bundler::BundleHelper do + def command_double(result, overrides = {}) + instance_double(PDK::CLI::Exec::Command, { + 'execute!' => result, + 'context=' => true, + 'add_spinner' => true, + 'environment' => {}, + 'update_environment' => {}, + }.merge(overrides)) + end - allow_command([bundle_regex, 'check', any_args], exit_code: 1, stdout: 'check stdout', stderr: 'check stderr') - allow($stderr).to receive(:puts).with('check stdout') - allow($stderr).to receive(:puts).with('check stderr') + def allow_command(argv, result = nil) + result ||= { exit_code: 0, stdout: '', stderr: '' } + + cmd = command_double(result) + + allow(PDK::CLI::Exec::Command).to receive(:new).with(*argv).and_return(cmd) + + cmd + end + + def expect_command(argv, result = nil, spinner_message = nil) + result ||= { exit_code: 0, stdout: '', stderr: '' } + + cmd = command_double(result) + + if spinner_message + expect(cmd).to receive(:add_spinner).with(spinner_message, any_args) end - it 'installs missing gems' do - allow(described_class).to receive(:already_bundled?).and_return(false) - expect_command([bundle_regex, 'install', any_args], nil, %r{installing missing gemfile}i) + # TODO: it would be nice to update 'expect_command' to allow options in any order but + # that would probably require making this more specific to bundler commands + expect(PDK::CLI::Exec::Command).to receive(:new).with(*argv).and_return(cmd) + + cmd + end + + def bundle_regex + %r{bundle(\.bat)?$} + end - described_class.ensure_bundle! + # Default to non-package install + include_context 'not packaged install' + + let(:instance) { described_class.new } + let(:bundle_cachedir) { '/bundle_cache' } + + before(:each) do + # Doesn't matter where this is since all the execs get mocked. + allow(PDK::Util).to receive(:module_root).and_return('/') + + allow(PDK::Util).to receive(:cachedir).and_return(bundle_cachedir) + end + + describe '#gemfile' do + subject { instance.gemfile } + + let(:gemfile_path) { '/Gemfile' } + + context 'when Gemfile exists' do + before(:each) do + allow(PDK::Util).to receive(:find_upwards).with(%r{Gemfile$}).and_return(gemfile_path) + end + + it { is_expected.to be gemfile_path } end - context 'and it fails to install the gems' do + context 'when Gemfile does not exist' do before(:each) do - allow(described_class).to receive(:already_bundled?).and_return(false) - allow_command([bundle_regex, 'install', any_args], exit_code: 1, stdout: 'install stdout', stderr: 'install stderr') - allow($stderr).to receive(:puts).with('install stdout') - allow($stderr).to receive(:puts).with('install stderr') + allow(PDK::Util).to receive(:find_upwards).with(%r{Gemfile$}).and_return(nil) end - it 'raises a FatalError' do - expect { - described_class.ensure_bundle! - }.to raise_error(PDK::CLI::FatalError, %r{unable to install missing gemfile dependencies}i) + it { is_expected.to be nil } + end + end + + describe '#gemfile?' do + subject { instance.gemfile? } + + context 'when Gemfile exists' do + before(:each) do + allow(PDK::Util).to receive(:find_upwards).with(%r{Gemfile$}).and_return('/Gemfile') end + + it { is_expected.to be true } end - it 'only attempts to install the gems once' do - expect(PDK::CLI::Exec::Command).not_to receive(:new) - expect(logger).to receive(:debug).with(%r{already been installed}) + context 'when Gemfile does not exist' do + before(:each) do + allow(PDK::Util).to receive(:find_upwards).with(%r{Gemfile$}).and_return(nil) + end - described_class.ensure_bundle! + it { is_expected.to be false } end end - context 'when there are no missing gems' do - before(:each) do - allow(File).to receive(:file?).with(%r{Gemfile$}).and_return(true) - allow(File).to receive(:file?).with(%r{Gemfile\.lock$}).and_return(true) - allow(described_class).to receive(:already_bundled?).and_return(false) + describe '#locked?' do + subject { instance.locked? } + + context 'when Gemfile.lock exists' do + before(:each) do + allow(PDK::Util).to receive(:find_upwards).with(%r{Gemfile\.lock$}).and_return('/Gemfile.lock') + end + + it { is_expected.to be true } end - it 'checks for missing but does not install anything' do - expect_command([bundle_regex, 'check', any_args]) - expect(logger).to receive(:debug).with(%r{checking for missing}i) - expect(PDK::CLI::Exec::Command).not_to receive(:new).with(bundle_regex, 'install', any_args) + context 'when Gemfile.lock does not exist' do + before(:each) do + allow(PDK::Util).to receive(:find_upwards).with(%r{Gemfile\.lock$}).and_return(nil) + end - described_class.ensure_bundle! + it { is_expected.to be false } end end - end - describe '.ensure_binstubs!' do - let(:gemfile) { '/path/to/Gemfile' } - let(:binstub_dir) { File.join(File.dirname(gemfile), 'bin') } - let(:gems) { %w[rspec pdk rake] } + describe '#installed?' do + let(:gemfile) { '/Gemfile' } - before(:each) do - allow(PDK::Util).to receive(:find_upwards).and_return(gemfile) + before(:each) do + allow(instance).to receive(:gemfile).and_return(gemfile) + end + + it 'invokes `bundle check`' do + expect_command([bundle_regex, 'check', "--gemfile=#{gemfile}", "--path=#{bundle_cachedir}"], exit_code: 0) + + instance.installed? + end + + it 'returns true if `bundle check` exits zero' do + allow_command([bundle_regex, 'check', "--gemfile=#{gemfile}", "--path=#{bundle_cachedir}"], exit_code: 0) + + expect(instance.installed?).to be true + end + + context 'when `bundle check` exits non-zero' do + before(:each) do + allow_command([bundle_regex, 'check', "--gemfile=#{gemfile}", "--path=#{bundle_cachedir}"], exit_code: 1, stderr: 'this is an error message') + end + + it 'returns false' do + expect(instance.installed?).to be false + end + + it 'logs a debug message with output' do + expect(logger).to receive(:debug).with(%r{error message}i) + + instance.installed? + end + end + + context 'packaged install' do + include_context 'packaged install' + + it 'invokes `bundle check` without --path option' do + expect_command([bundle_regex, 'check', "--gemfile=#{gemfile}"], exit_code: 0) + + instance.installed? + end + end + + context 'with gem overrides' do + let(:overrides) { { puppet: '1.2.3' } } + + it 'updates env before invoking `bundle check`' do + cmd_double = allow_command([bundle_regex, 'check', "--gemfile=#{gemfile}", "--path=#{bundle_cachedir}"], exit_code: 0) + + expect(cmd_double).to receive(:update_environment).with(hash_including('PUPPET_GEM_VERSION' => '1.2.3')) + + instance.installed?(overrides) + end + end end - context 'when the binstubs do not already exist' do + describe '#lock!' do before(:each) do - gems.each { |gem| allow(File).to receive(:file?).with(File.join(binstub_dir, gem)).and_return(false) } + allow_command([bundle_regex, 'lock', '--update=json', '--local'], exit_code: 0) end - it 'generates the requested binstubs' do - expect_command([bundle_regex, 'binstubs', *gems, '--force']) + it 'invokes `bundle lock`' do + expect_command([bundle_regex, 'lock'], exit_code: 0) - described_class.ensure_binstubs!(*gems) + instance.lock! + end + + it 'returns true if `bundle lock` exits zero' do + allow_command([bundle_regex, 'lock'], exit_code: 0) + + expect(instance.lock!).to be true + end + + it 'invokes #update_lock! to re-resolve json dependency locally' do + allow_command([bundle_regex, 'lock'], exit_code: 0) + + expect(instance).to receive(:update_lock!).with(hash_including(:json), hash_including(local: true)).and_return(true) + + instance.lock! + end + + context 'when `bundle lock` exits non-zero' do + before(:each) do + allow_command([bundle_regex, 'lock'], exit_code: 1, stderr: 'bundle lock error message') + end + + it 'logs a fatal message with output and raises FatalError' do + expect(logger).to receive(:fatal).with(%r{bundle lock error message}i) + expect { instance.lock! }.to raise_error(PDK::CLI::FatalError, %r{unable to resolve}i) + end + end + + context 'with gem overrides' do + let(:overrides) { { puppet: '1.2.3' } } + + it 'updates env before invoking `bundle lock`' do + cmd_double = allow_command([bundle_regex, 'lock'], exit_code: 0) + + expect(cmd_double).to receive(:update_environment).with(hash_including('PUPPET_GEM_VERSION' => '1.2.3')) + + instance.lock!(overrides) + end + end + + context 'packaged install' do + include_context 'packaged install' + + before(:each) do + # package_cachedir comes from 'packaged install' context + allow(File).to receive(:exist?).with("#{package_cachedir}/Gemfile.lock").and_return(true) + + allow(FileUtils).to receive(:cp) + end + + it 'copies a Gemfile.lock from vendored location' do + # package_cachedir comes from 'packaged install' context + expect(FileUtils).to receive(:cp).with("#{package_cachedir}/Gemfile.lock", %r{Gemfile\.lock$}) + + instance.lock! + end + + it 'logs a debug message about using vendored Gemfile.lock' do + expect(logger).to receive(:debug).with(%r{vendored gemfile\.lock}i) + + instance.lock! + end + + context 'when vendored Gemfile.lock does not exist' do + before(:each) do + allow(File).to receive(:exist?).with("#{package_cachedir}/Gemfile.lock").and_return(false) + end + + it 'raises FatalError' do + expect { instance.lock! }.to raise_error(PDK::CLI::FatalError, %r{vendored gemfile\.lock.*not found}i) + end + end + + context 'with gem overrides' do + let(:overrides) { { puppet: '1.2.3' } } + + it 'invokes #update_lock! with overrides to re-resolve locally' do + allow(instance).to receive(:update_lock!).with(hash_including(:json), hash_including(local: true)).and_return(true) + + expect(instance).to receive(:update_lock!).with(overrides, hash_including(local: true)).and_return(true) + + instance.lock!(overrides) + end + end end end - context 'when all the requested binstubs exist' do - before(:each) do - gems.each { |gem| allow(File).to receive(:file?).with(File.join(binstub_dir, gem)).and_return(true) } + describe '#update_lock!' do + let(:overrides) { { puppet: '1.2.3' } } + let(:overridden_gems) { overrides.keys.map(&:to_s) } + + it 'updates env before invoking `bundle lock --update`' do + cmd_double = allow_command([bundle_regex, 'lock', "--update=#{overridden_gems.join(' ')}"], exit_code: 0) + + expect(cmd_double).to receive(:update_environment).with(hash_including('PUPPET_GEM_VERSION' => '1.2.3')) + + instance.update_lock!(overrides) end - it 'does not regenerate the requested binstubs' do - expect(PDK::CLI::Exec::Command).not_to receive(:new).with(bundle_regex, 'binstubs', any_args) + it 'invokes `bundle lock --update`' do + expect_command([bundle_regex, 'lock', "--update=#{overridden_gems.join(' ')}"], exit_code: 0) - described_class.ensure_binstubs!(*gems) + instance.update_lock!(overrides) + end + + context 'when `bundle lock --update` exits non-zero' do + before(:each) do + allow_command([bundle_regex, 'lock', "--update=#{overridden_gems.join(' ')}"], exit_code: 1, stderr: 'bundle lock update error message') + end + + it 'logs a fatal message with output and raises FatalError' do + expect(logger).to receive(:fatal).with(%r{bundle lock update error message}i) + expect { instance.update_lock!(overrides) }.to raise_error(PDK::CLI::FatalError, %r{unable to resolve}i) + end + end + + context 'with multiple overrides' do + let(:overrides) { { puppet: '1.2.3', facter: '2.3.4' } } + + it 'includes all gem names in `bundle lock --update` invocation' do + expect_command([bundle_regex, 'lock', "--update=#{overridden_gems.join(' ')}"], exit_code: 0) + + instance.update_lock!(overrides) + end + end + + context 'with local option set' do + let(:options) { { local: true } } + + it 'includes \'--local\' in `bundle lock --update` invocation' do + expect_command([bundle_regex, 'lock', "--update=#{overridden_gems.join(' ')}", '--local'], exit_code: 0) + + instance.update_lock!(overrides, options) + end + end + + context 'with no overrides' do + let(:overrides) { {} } + + it 'returns true early' do + expect(PDK::CLI::Exec::Command).not_to receive(:new) + + expect(instance.update_lock!(overrides)).to be true + end end end - context 'when not all of the requested binstubs exist' do + describe '#install!' do + let(:gemfile) { '/Gemfile' } + before(:each) do - allow(File).to receive(:file?).with(File.join(binstub_dir, 'rake')).and_return(true) - allow(File).to receive(:file?).with(File.join(binstub_dir, 'rspec')).and_return(false) - allow(File).to receive(:file?).with(File.join(binstub_dir, 'pdk')).and_return(true) + allow(instance).to receive(:gemfile).and_return(gemfile) end - it 'generates the requested binstubs' do - expect_command([bundle_regex, 'binstubs', *gems, '--force']) + it 'invokes `bundle install`' do + expect_command([bundle_regex, 'install', "--gemfile=#{gemfile}", '-j4', "--path=#{bundle_cachedir}"], exit_code: 0) - described_class.ensure_binstubs!(*gems) + instance.install! + end + + it 'returns true if `bundle install` exits zero' do + allow_command([bundle_regex, 'install', "--gemfile=#{gemfile}", '-j4', "--path=#{bundle_cachedir}"], exit_code: 0) + + expect(instance.install!).to be true + end + + context 'when `bundle install` exits non-zero' do + before(:each) do + allow_command([bundle_regex, 'install', "--gemfile=#{gemfile}", '-j4', "--path=#{bundle_cachedir}"], exit_code: 1, stderr: 'bundle install error message') + end + + it 'logs a fatal message with output and raises FatalError' do + expect(logger).to receive(:fatal).with(%r{bundle install error message}i) + expect { instance.install! }.to raise_error(PDK::CLI::FatalError, %r{unable to install}i) + end + end + + context 'packaged install' do + include_context 'packaged install' + + it 'invokes `bundle install` without --path option' do + expect_command([bundle_regex, 'install', "--gemfile=#{gemfile}", '-j4'], exit_code: 0) + + instance.install! + end + end + + context 'with gem overrides' do + let(:overrides) { { puppet: '1.2.3' } } + + it 'updates env before invoking `bundle install`' do + cmd_double = allow_command([bundle_regex, 'install', "--gemfile=#{gemfile}", '-j4', "--path=#{bundle_cachedir}"], exit_code: 0) + + expect(cmd_double).to receive(:update_environment).with(hash_including('PUPPET_GEM_VERSION' => '1.2.3')) + + instance.install!(overrides) + end end end - context 'when it fails to generate the binstubs' do + describe '#binstubs!' do + let(:gemfile) { '/Gemfile' } + let(:requested_gems) { %w[rake rspec metadata-json-lint] } + before(:each) do - gems.each { |gem| allow(File).to receive(:file?).with(File.join(binstub_dir, gem)).and_return(false) } - allow_command([bundle_regex, 'binstubs', *gems, '--force'], exit_code: 1, stdout: 'binstubs stdout', stderr: 'binstubs stderr') - allow($stderr).to receive(:puts).with('binstubs stdout') - allow($stderr).to receive(:puts).with('binstubs stderr') + allow(instance).to receive(:gemfile).and_return(gemfile) + allow(File).to receive(:file?).and_return(false) end - it 'raises a fatal error' do - expect(logger).to receive(:fatal).with(a_string_matching(%r{failed to generate binstubs}i)) + it 'invokes `bundle binstubs` with requested gems' do + expect_command([bundle_regex, 'binstubs', requested_gems, '--force'].flatten, exit_code: 0) - expect { - described_class.ensure_binstubs!(*gems) - }.to raise_error(PDK::CLI::FatalError, %r{unable to install requested binstubs}i) + instance.binstubs!(requested_gems) + end + + it 'returns true if `bundle install` exits zero' do + allow_command([bundle_regex, 'binstubs', requested_gems, '--force'].flatten, exit_code: 0) + + expect(instance.binstubs!(requested_gems)).to be true + end + + context 'when `bundle binstubs` exits non-zero' do + before(:each) do + allow_command([bundle_regex, 'binstubs', requested_gems, '--force'].flatten, exit_code: 1, stderr: 'bundle binstubs error message') + end + + it 'logs a fatal message with output and raises FatalError' do + expect(logger).to receive(:fatal).with(%r{bundle binstubs error message}i) + expect { instance.binstubs!(requested_gems) }.to raise_error(PDK::CLI::FatalError, %r{unable to install.*binstubs}i) + end + end + + context 'when binstubs for all requested gems are already present' do + before(:each) do + requested_gems.each do |gemname| + allow(File).to receive(:file?).with(%r{#{gemname}$}).and_return(true) + end + end + + it 'returns true early' do + expect(PDK::CLI::Exec::Command).not_to receive(:new) + + expect(instance.binstubs!(requested_gems)).to be true + end end end end diff --git a/spec/unit/pdk/util/ruby_version_spec.rb b/spec/unit/pdk/util/ruby_version_spec.rb index e7bbe7fc8..56ad919cb 100644 --- a/spec/unit/pdk/util/ruby_version_spec.rb +++ b/spec/unit/pdk/util/ruby_version_spec.rb @@ -5,13 +5,26 @@ let(:instance) { described_class.new } shared_context 'is a package install' do - before(:each) do - allow(PDK::Util).to receive(:package_install?).and_return(true) - allow(PDK::Util).to receive(:package_cachedir).and_return(package_cachedir) + let(:pdk_package_basedir) do + File.join('/', 'path', 'to', 'pdk') end let(:package_cachedir) do - File.join('/', 'path', 'to', 'pdk', 'share', 'cache') + File.join(pdk_package_basedir, 'share', 'cache') + end + + let(:packaged_rubies) do + { + '2.4.3' => '2.4.0', + '2.1.9' => '2.1.0', + } + end + + before(:each) do + allow(PDK::Util).to receive(:package_install?).and_return(true) + allow(PDK::Util).to receive(:pdk_package_basedir).and_return(pdk_package_basedir) + allow(PDK::Util).to receive(:package_cachedir).and_return(package_cachedir) + allow(described_class).to receive(:scan_for_packaged_rubies).and_return(packaged_rubies) end end @@ -29,8 +42,8 @@ context 'when running from a package install' do include_context 'is a package install' - it 'returns the path to the packaged ruby cachedir' do - is_expected.to eq(File.join(package_cachedir, 'ruby', described_class.versions[described_class.active_ruby_version])) + it 'includes the path to the packaged ruby cachedir' do + is_expected.to include(File.join(package_cachedir, 'ruby', described_class.versions[described_class.active_ruby_version])) end end