Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(GH-1688) Add BoltSpec helper to load bolt constructs #1712

Merged
merged 2 commits into from Apr 13, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 7 additions & 0 deletions Rakefile
Expand Up @@ -45,6 +45,12 @@ RSpec::Core::RakeTask.new(:slow) do |t|
t.rspec_opts = '--tag puppetserver --tag puppetdb --tag expensive'
end

task :bolt_spec do
Dir.chdir("#{__dir__}/bolt_spec_spec/") do
sh "rake spec"
end
end

RuboCop::RakeTask.new(:rubocop) do |t|
t.options = ['--display-cop-names', '--display-style-guide', '--parallel']
end
Expand All @@ -63,6 +69,7 @@ end
namespace :ci do
task :fast do
Rake::Task['fast'].invoke
Rake::Task['bolt_spec'].invoke
end

task :slow do
Expand Down
3 changes: 3 additions & 0 deletions bolt_spec_spec/Rakefile
@@ -0,0 +1,3 @@
# frozen_string_literal: true

require 'puppetlabs_spec_helper/rake_tasks'
6 changes: 6 additions & 0 deletions bolt_spec_spec/functions/with_datatype.pp
@@ -0,0 +1,6 @@
function bolt_spec_spec::with_datatype (
Boltlib::TargetSpec $target
) {
out::message("Loaded TargetSpec ${$target}")
$target
}
20 changes: 20 additions & 0 deletions bolt_spec_spec/spec/functions/with_datatype_spec.rb
@@ -0,0 +1,20 @@
# frozen_string_literal: true

require 'puppetlabs_spec_helper/module_spec_helper'
require 'spec_helper'
require 'bolt_spec/bolt_context'

describe 'bolt_spec_spec::with_datatype' do
include BoltSpec::BoltContext

around :each do |example|
in_bolt_context do
example.run
end
end

it "bolt_context runs a Puppet function with Bolt datatypes" do
expect_out_message.with_params("Loaded TargetSpec localhost")
is_expected.to run.with_params('localhost').and_return('localhost')
end
end
3 changes: 3 additions & 0 deletions bolt_spec_spec/spec/spec_helper.rb
@@ -0,0 +1,3 @@
# frozen_string_literal: true

$LOAD_PATH.unshift File.join(__dir__, '..', '..', 'lib')
13 changes: 12 additions & 1 deletion lib/bolt/shell/bash.rb
Expand Up @@ -378,11 +378,22 @@ def execute(command, sudoable: false, **options)
else
!ready_write.nil?
end
retries = 0

begin
if writable && index < in_buffer.length
to_print = in_buffer[index..-1]
written = inp.write_nonblock to_print
begin
written = inp.write_nonblock to_print
rescue IO::WaitWritable, Errno::EINTR => e
IO.select(nil, [io])
retries += 1
if retries < 4
retry
else
raise e
end
end
index += written

if index >= in_buffer.length && !write_stream.empty?
Expand Down
206 changes: 206 additions & 0 deletions lib/bolt_spec/bolt_context.rb
@@ -0,0 +1,206 @@
# frozen_string_literal: true

require 'bolt_spec/plans/mock_executor'
require 'bolt/config'
require 'bolt/inventory'
require 'bolt/pal'
require 'bolt/plugin'

# This helper is used to create the Bolt context necessary to load Bolt plan
# datatypes and functions. It accomplishes this by replacing bolt's executor
# with a mock executor. The mock executor allows calls to run_* functions to be
# stubbed out for testing. By default this executor will fail on any run_* call
# but stubs can be set up with allow_* and expect_* functions.
#
# Stub matching
#
# Stubs match invocations of run_* functions by default matching any call but
# with_targets and with_params helpers can further restrict the stub to match
# more exact invocations. It's possible a call to run_* could match multiple
# stubs. In this case the mock executor will first check for stubs specifically
# matching the task being run after which it will use the last stub that
# matched
#
#
# allow vs expect
#
# Stubs have two general modes bases on whether the test is making assertions
# on whether function was called. Allow stubs allow the run_* invocation to
# be called any number of times while expect stubs will fail if no run_*
# invocation matches them. The be_called_times(n) stub method can be used to
# ensure an allow stub is not called more than n times or that an expect stub
# is called exactly n times.
#
# Configuration
#
# By default the plan helpers use the modulepath set up for rspec-puppet and
# an otherwise empty bolt config and inventory. To create your own values for
# these override the modulepath, config, or inventory methods.
#
# Stubs:
# - allow_command(cmd), expect_command(cmd): expect the exact command
# - allow_script(script), expect_script(script): expect the script as <module>/path/to/file
# - allow_task(task), expect_task(task): expect the named task
# - allow_upload(file), expect_upload(file): expect the identified source file
# - allow_out_message, expect_out_message: expect a message to be passed to out::message (only modifiers are
# be_called_times(n), with_params(params), and not_be_called)
#
# Stub modifiers:
# - be_called_times(n): if allowed, fail if the action is called more than 'n' times
# if expected, fail unless the action is called 'n' times
# - not_be_called: fail if the action is called
# - with_targets(targets): target or list of targets that you expect to be passed to the action
# - with_params(params): list of params and metaparams (or options) that you expect to be passed to the action.
# Corresponds to the action's last argument.
# - with_destination(dest): for upload_file, the expected destination path
# - always_return(value): return a Bolt::ResultSet of Bolt::Result objects with the specified value Hash
# command and script: only accept 'stdout' and 'stderr' keys
# upload: does not support this modifier
# - return_for_targets(targets_to_values): return a Bolt::ResultSet of Bolt::Result objects from the Hash mapping
# targets to their value Hashes
# command and script: only accept 'stdout' and 'stderr' keys
# upload: does not support this modifier
# - return(&block): invoke the block to construct a Bolt::ResultSet. The blocks parameters differ based on action
# command: `{ |targets:, command:, params:| ... }`
# script: `{ |targets:, script:, params:| ... }`
# task: `{ |targets:, task:, params:| ... }`
# upload: `{ |targets:, source:, destination:, params:| ... }`
# - error_with(err): return a failing Bolt::ResultSet, with Bolt::Result objects with the identified err hash
#
# Example:
#
# describe "mymod::myfunction" do
# include BoltSpec::BoltContext
#
# around :each do |example|
# in_bolt_context do
# example.run
# end
# end
#
# it "bolt_context runs a Puppet function with Bolt datatypes" do
# expect_out_message.with_params("Loaded TargetSpec localhost")
# is_expected.to run.with_params('localhost').and_return('localhost')
# end
# end

module BoltSpec
module BoltContext
def setup
unless @loaded
# This is slow so don't do it until we have to
Bolt::PAL.load_puppet
@loaded = true
end
end

def in_bolt_context(&block)
setup
old_modpath = RSpec.configuration.module_path
old_tasks = Puppet[:tasks]

# Set the things
Puppet[:tasks] = true
RSpec.configuration.module_path = [modulepath, Bolt::PAL::BOLTLIB_PATH].join(File::PATH_SEPARATOR)
opts = {
bolt_executor: executor,
bolt_inventory: inventory,
bolt_pdb_client: nil,
apply_executor: nil
}
Puppet.override(opts, &block)

# Unset the things
RSpec.configuration.module_path = old_modpath
Puppet[:tasks] = old_tasks
end

# Override in your tests if needed
def modulepath
[RSpec.configuration.module_path]
rescue NoMethodError
raise "RSpec.configuration.module_path not defined set up rspec puppet or define modulepath for this test"
end

def executor
@executor ||= BoltSpec::Plans::MockExecutor.new(modulepath)
end

# Override in your tests
def inventory_data
{}
end

def inventory
@inventory ||= Bolt::Inventory.create_version(inventory_data, config.transport, config.transports, plugins)
end

# Override in your tests
def config
@config ||= begin
conf = Bolt::Config.new(Bolt::Boltdir.new('.'), {})
conf.modulepath = [modulepath].flatten
conf
end
end

def plugins
@plugins ||= Bolt::Plugin.setup(config,
pal,
nil,
Bolt::Analytics::NoopClient.new)
end

def pal
@pal ||= Bolt::PAL.new(config.modulepath, config.hiera_config, config.boltdir.resource_types)
end

BoltSpec::Plans::MOCKED_ACTIONS.each do |action|
# Allowed action stubs can be called up to be_called_times number of times
define_method :"allow_#{action}" do |object|
executor.send(:"stub_#{action}", object).add_stub
end

# Expected action stubs must be called exactly the expected number of times
# or at least once without be_called_times
define_method :"expect_#{action}" do |object|
send(:"allow_#{action}", object).expect_call
end

# This stub will catch any action call if there are no stubs specifically for that task
define_method :"allow_any_#{action}" do
executor.send(:"stub_#{action}", :default).add_stub
end
end

# Does this belong here?
def allow_out_message
executor.stub_out_message.add_stub
end
alias allow_any_out_message allow_out_message

def expect_out_message
allow_out_message.expect_call
end

# Example helpers to mock other run functions
# The with_targets method makes sense for all stubs
# with_params could be reused for options
# They probably need special stub methods for other arguments through

# Scripts can be mocked like tasks by their name
# arguments is an array instead of a hash though
# so it probably should be set separately
# def allow_script(script_name)
#
# file uploads have a single destination and no arguments
# def allow_file_upload(source_name)
#
# Most of the information in commands is in the command string itself
# we may need more flexible allows than just the name/command string
# Only option params exist on a command.
# def allow_command(command)
# def allow_command_matching(command_regex)
# def allow_command(&block)
end
end