diff --git a/Gemfile b/Gemfile index 8ad7d6d..5176bd2 100644 --- a/Gemfile +++ b/Gemfile @@ -24,8 +24,11 @@ group :development do gem "puppet-module-posix-dev-r#{minor_version}", '~> 1.0', require: false, platforms: [:ruby] gem "puppet-module-win-default-r#{minor_version}", '~> 1.0', require: false, platforms: [:mswin, :mingw, :x64_mingw] gem "puppet-module-win-dev-r#{minor_version}", '~> 1.0', require: false, platforms: [:mswin, :mingw, :x64_mingw] - gem "puppet-resource_api", require: false, git: 'https://github.com/michaeltlombardi/puppet-resource_api', branch: 'gh-225/main/custom-insync' + gem "puppet-resource_api", require: false gem "github_changelog_generator", require: false + gem 'retriable', '~> 3.1', require: false + gem 'pry-byebug', require: false + gem 'webmock', require: false end group :system_tests do gem "puppet-module-posix-system-r#{minor_version}", '~> 1.0', require: false, platforms: [:ruby] diff --git a/lib/puppet/provider/check_http/check_http.rb b/lib/puppet/provider/check_http/check_http.rb index 77111e9..8fabbdc 100644 --- a/lib/puppet/provider/check_http/check_http.rb +++ b/lib/puppet/provider/check_http/check_http.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true +require 'puppet/resource_api' +require 'puppet/resource_api/simple_provider' require 'net/http' +require 'retriable' # Implementation for the check_http type using the Resource API. class Puppet::Provider::CheckHttp::CheckHttp @@ -8,14 +11,36 @@ def get(_context) [] end - def set(context, changes) - changes.each do |name, _change| - uri = URI(name) - context.processing(uri.to_s, {}, {}, message: 'checking http') do - if Net::HTTP.get(uri) - context.info("successfully connected to #{name}") - end + def set(context, changes); end + + # Update the check_http provider to use the above attributes to execute up to retries number of times + # with success being defined as having one of the expected_statuses + # and the body of the response matches body_matcher while taking into account request_timeout. + + def insync?(context, name, attribute_name, _is_hash, should_hash) + context.debug("Checking whether #{attribute_name} is up-to-date") + uri = URI.parse(should_hash[:url]) + + # This callback provides the exception that was raised in the current try, the try_number, the elapsed_time for all tries so far, and the time in seconds of the next_interval. + do_this_on_each_retry = proc do |exception, try, elapsed_time, next_interval| + context.info("#{exception.class}: '#{exception.message}' - #{try} tries in #{elapsed_time} seconds and #{next_interval} seconds until the next try.") unless exception.nil? + end + + Retriable.retriable(tries: should_hash[:retries], max_elapsed_time: should_hash[:request_timeout], max_interval: should_hash[:max_backoff], +multiplier: should_hash[:exponential_backoff_base], on_retry: do_this_on_each_retry) do + response = Net::HTTP.get_response(uri) + + unless should_hash[:expected_statuses].include? response.code.to_i + raise Puppet::Error, "check_http response code check failed. The return response '#{response.code}' is not matching with the expected_statuses '#{should_hash[:expected_statuses]}.to_s'" + end + context.debug("The return response '#{response.code}' is matching with the expected_statuses '#{should_hash[:expected_statuses]}'") + unless response.body.match(should_hash[:body_matcher]) + raise Puppet::Error, "check_http response body check failed. The return response body '#{response.body[0..99]}' is not matching body_matcher '#{should_hash[:body_matcher].to_s}'" end + context.debug("The return response body '#{response.body[0..99]}' is matching with body_matcher '#{should_hash[:body_matcher].to_s}'") + context.debug("Successfully connected to '#{name}'") + return true end + false end end diff --git a/lib/puppet/type/check_http.rb b/lib/puppet/type/check_http.rb index 33f79a5..1350e5f 100644 --- a/lib/puppet/type/check_http.rb +++ b/lib/puppet/type/check_http.rb @@ -11,17 +11,66 @@ Use this to check whether a web server is responding correctly. This can be used both as a prerequisite (don't manage something if a dependency is unhealthy) or to check whether everything went right after managing something. EOS - features: [], + features: ['custom_insync'], attributes: { - ensure: { - type: 'Enum[present, absent]', - desc: 'Set to `absent` to temporarily disable a check.', - default: 'present', - }, url: { type: 'String', desc: 'The URL to test.', behaviour: :namevar, }, + headers: { + type: 'Hash[String, String]', + desc: 'A hash of headers to pass along with the request.', + behaviour: :parameter, + default: {}, + }, + expected_statuses: { + type: 'Array[Integer]', + desc: 'An array of acceptable HTTP status codes. If a request returns one of these status codes, it is considered a success', + behaviour: :parameter, + default: [200], + }, + body_matcher: { + type: 'Regexp', + desc: 'A request is considered a success if the body of the HTTP response matches this regular expression', + behaviour: :parameter, + default: //, + }, + request_timeout: { + type: 'Numeric', + desc: 'Number of seconds for a single request to wait for a response to return a success before aborting.', + behaviour: :parameter, + default: 10, + }, + retries: { + type: 'Integer', + desc: 'Number of requests to make before giving up.', + behaviour: :parameter, + default: 3, + }, + backoff: { + type: 'Numeric', + desc: 'Initial number of seconds to wait between requests.', + behaviour: :parameter, + default: 1, + }, + exponential_backoff_base: { + type: 'Numeric', + desc: 'Exponential base for the exponential backoff calculations.', + behaviour: :parameter, + default: 2, + }, + max_backoff: { + type: 'Numeric', + desc: 'An upper limit to the backoff duration,.', + behaviour: :parameter, + default: 10, + }, + timeout: { + type: 'Numeric', + desc: 'Number of seconds allocated overall for the check to return a success before giving up.', + behaviour: :parameter, + default: 60, + }, }, ) diff --git a/spec/acceptance/check_http.pp b/spec/acceptance/check_http.pp new file mode 100644 index 0000000..9fe6adb --- /dev/null +++ b/spec/acceptance/check_http.pp @@ -0,0 +1,10 @@ +check_http {'https://www.google.com': + expected_statuses => [200], + body_matcher => /Google/, + request_timeout => 30, + retries => 3, + backoff => 1, + exponential_backoff_base => 2, + max_backoff => 40, + timeout => 60, +} diff --git a/spec/unit/puppet/provider/check_http/check_http_spec.rb b/spec/unit/puppet/provider/check_http/check_http_spec.rb index df9737d..d863752 100644 --- a/spec/unit/puppet/provider/check_http/check_http_spec.rb +++ b/spec/unit/puppet/provider/check_http/check_http_spec.rb @@ -1,52 +1,98 @@ # frozen_string_literal: true require 'spec_helper' +require 'webmock/rspec' ensure_module_defined('Puppet::Provider::CheckHttp') require 'puppet/provider/check_http/check_http' RSpec.describe Puppet::Provider::CheckHttp::CheckHttp do subject(:provider) { described_class.new } + WebMock.disable_net_connect!(allow_localhost: true) - let(:context) { instance_double('Puppet::ResourceApi::BaseContext', 'context') } + let(:context) { double('Puppet::ResourceApi::BaseContext') } + let(:valid_uri) { 'https://www.google.com' } + let(:invalid_uri) { 'https://abc.test.net' } + let(:valid_hash) { { name: 'foo', url: valid_uri, ensure: 'present',expected_statuses: [200], body_matcher: /Google/, request_timeout: 30, retries: 3, backoff: 1, exponential_backoff_base:2, max_backoff:40, timeout:60 } } + let(:invalid_hash) { { name: 'foos', url: invalid_uri, ensure: 'present',expected_statuses: [200], body_matcher: /Google/, request_timeout: 30, retries: 3, backoff: 1, exponential_backoff_base:2, max_backoff:40, timeout:60 } } - describe '#get' do + describe 'get(context)' do it 'processes resources' do - expect(context).to receive(:debug).with('Returning pre-canned example data') - expect(provider.get(context)).to eq [ - { - name: 'foo', - ensure: 'present', - }, - { - name: 'bar', - ensure: 'present', - }, - ] + expect(provider.get(context)).to eq [] end end - describe 'create(context, name, should)' do - it 'creates the resource' do - expect(context).to receive(:notice).with(%r{\ACreating 'a'}) - - provider.create(context, 'a', name: 'a', ensure: 'present') + describe 'insync?(context, name, attribute_name, is_hash, should_hash) without Retry' do + it 'processes resources' do + stub_request(:get, "https://www.google.com/"). + with( + headers: { + 'Accept'=>'*/*', + 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'Host'=>'www.google.com', + 'User-Agent'=>'Ruby' + }).to_return(status: 200, body: "Google", headers: {}) + expect(context).to receive(:debug).with('Checking whether foo is up-to-date') + expect(context).to receive(:debug).with("The return response '200' is matching with the expected_statuses '[200]'") + expect(context).to receive(:debug).with("The return response body 'Google' is matching with body_matcher '(?-mix:Google)'") + expect(context).to receive(:debug).with("Successfully connected to 'foo'") + expect(provider.insync?(context, 'foo', 'foo', valid_hash, valid_hash)).to be(true) end end - describe 'update(context, name, should)' do - it 'updates the resource' do - expect(context).to receive(:notice).with(%r{\AUpdating 'foo'}) + describe 'insync?(context, name, attribute_name, is_hash, should_hash) expected_status not matching' do + it 'processes resources' do + stub_request(:get, invalid_uri). + with( + headers: { + 'Accept'=>'*/*', + 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'Host'=>'abc.test.net', + 'User-Agent'=>'Ruby' + }).to_return(status: 500, body: "invalidbody", headers: {}) + allow(context).to receive(:debug) + allow(context).to receive(:debug) + expect(context).to receive(:debug).with('Checking whether foo is up-to-date') + expect { provider.insync?(context, 'foo', 'foo', invalid_hash, invalid_hash) }.to raise_error(/check_http response code check failed./) + end + end - provider.update(context, 'foo', name: 'foo', ensure: 'present') + describe 'insync?(context, name, attribute_name, is_hash, should_hash) body_matcher not matching' do + it 'processes resources' do + stub_request(:get, invalid_uri). + with( + headers: { + 'Accept'=>'*/*', + 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'Host'=>'abc.test.net', + 'User-Agent'=>'Ruby' + }).to_return(status: 200, body: "invalidbody", headers: {}) + allow(context).to receive(:debug) + allow(context).to receive(:debug) + expect(context).to receive(:debug).with('Checking whether foo is up-to-date') + expect(context).to receive(:debug).with("The return response '200' is matching with the expected_statuses '[200]'") + expect { provider.insync?(context, 'foo', 'foo', invalid_hash, invalid_hash) }.to raise_error(/check_http response body check failed./) end end - describe 'delete(context, name)' do - it 'deletes the resource' do - expect(context).to receive(:notice).with(%r{\ADeleting 'foo'}) + describe 'insync?(context, name, attribute_name, is_hash, should_hash) with Retry' do + it 'processes resources' do + allow(context).to receive(:debug) + allow(context).to receive(:debug) + expect(context).to receive(:debug).with('Checking whether foo is up-to-date') + stub_request(:get, invalid_uri). + with( + headers: { + 'Accept'=>'*/*', + 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'Host'=>'abc.test.net', + 'User-Agent'=>'Ruby' + }).to_raise(StandardError) + expect(context).to receive(:info).with(/StandardError: 'Exception from WebMock' - 1 tries/) + expect(context).to receive(:info).with(/StandardError: 'Exception from WebMock' - 2 tries/) + expect(context).to receive(:info).with(/StandardError: 'Exception from WebMock' - 3 tries/) - provider.delete(context, 'foo') + expect { provider.insync?(context, 'foo', 'foo', invalid_hash, invalid_hash) }.to raise_error(StandardError) end end end