Skip to content

Commit a74bd25

Browse files
authored
Merge pull request #282 from puppetlabs/bug-retry_on_invokedsc_collision
(feat) - add retries on failed dsc invocation
2 parents 36ed427 + 3e97cbf commit a74bd25

File tree

3 files changed

+91
-8
lines changed

3 files changed

+91
-8
lines changed

.rubocop_todo.yml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# This configuration was generated by
22
# `rubocop --auto-gen-config`
3-
# on 2024-01-23 11:29:18 UTC using RuboCop version 1.50.2.
3+
# on 2024-01-30 16:36:55 UTC using RuboCop version 1.50.2.
44
# The point is for the user to remove these configuration records
55
# one by one as the offenses are removed from the code base.
66
# Note that changes in the inspected code, or installation of new
@@ -46,14 +46,14 @@ Metrics/BlockLength:
4646
# Offense count: 2
4747
# Configuration parameters: CountComments, CountAsOne.
4848
Metrics/ClassLength:
49-
Max: 526
49+
Max: 553
5050

5151
# Offense count: 12
5252
# Configuration parameters: AllowedMethods, AllowedPatterns.
5353
Metrics/CyclomaticComplexity:
5454
Max: 24
5555

56-
# Offense count: 22
56+
# Offense count: 23
5757
# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
5858
Metrics/MethodLength:
5959
Max: 42
@@ -118,12 +118,12 @@ RSpec/DescribeClass:
118118
- 'spec/acceptance/dsc/complex.rb'
119119
- 'spec/unit/pwsh_spec.rb'
120120

121-
# Offense count: 31
121+
# Offense count: 32
122122
# Configuration parameters: CountAsOne.
123123
RSpec/ExampleLength:
124124
Max: 70
125125

126-
# Offense count: 102
126+
# Offense count: 105
127127
# Configuration parameters: .
128128
# SupportedStyles: have_received, receive
129129
RSpec/MessageSpies:
@@ -134,7 +134,7 @@ RSpec/MultipleDescribes:
134134
Exclude:
135135
- 'spec/unit/pwsh_spec.rb'
136136

137-
# Offense count: 147
137+
# Offense count: 151
138138
RSpec/MultipleExpectations:
139139
Max: 15
140140

@@ -156,7 +156,7 @@ RSpec/NoExpectationExample:
156156
- 'spec/unit/pwsh/windows_powershell_spec.rb'
157157
- 'spec/unit/pwsh_spec.rb'
158158

159-
# Offense count: 55
159+
# Offense count: 56
160160
RSpec/StubbedMock:
161161
Exclude:
162162
- 'spec/unit/puppet/provider/dsc_base_provider/dsc_base_provider_spec.rb'

lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
require 'pathname'
66
require 'json'
77

8-
class Puppet::Provider::DscBaseProvider
8+
class Puppet::Provider::DscBaseProvider # rubocop:disable Metrics/ClassLength
99
# Initializes the provider, preparing the instance variables which cache:
1010
# - the canonicalized resources across calls
1111
# - query results
@@ -244,6 +244,7 @@ def invoke_dsc_resource(context, name_hash, props, method)
244244
script_content = ps_script_content(resource)
245245
context.debug("Script:\n #{redact_secrets(script_content)}")
246246
output = ps_manager.execute(remove_secret_identifiers(script_content))[:stdout]
247+
247248
if output.nil?
248249
context.err('Nothing returned')
249250
return nil
@@ -256,8 +257,10 @@ def invoke_dsc_resource(context, name_hash, props, method)
256257
return nil
257258
end
258259
context.debug("raw data received: #{data.inspect}")
260+
collision_error_matcher = /The Invoke-DscResource cmdlet is in progress and must return before Invoke-DscResource can be invoked/
259261

260262
error = data['errormessage']
263+
261264
unless error.nil? || error.empty?
262265
# NB: We should have a way to stop processing this resource *now* without blowing up the whole Puppet run
263266
# Raising an error stops processing but blows things up while context.err alerts but continues to process
@@ -267,6 +270,11 @@ def invoke_dsc_resource(context, name_hash, props, method)
267270
@logon_failures << name_hash[:dsc_psdscrunascredential].dup
268271
# This is a hack to handle the query cache to prevent a second lookup
269272
@cached_query_results << name_hash # if fetch_cached_hashes(@cached_query_results, [data]).empty?
273+
elsif error.match?(collision_error_matcher)
274+
context.notice('Invoke-DscResource collision detected: Please stagger the timing of your Puppet runs as this can lead to unexpected behaviour.')
275+
retry_invoke_dsc_resource(context, 5, 60, collision_error_matcher) do
276+
data = ps_manager.execute(remove_secret_identifiers(script_content))[:stdout]
277+
end
270278
else
271279
context.err(error)
272280
end
@@ -276,6 +284,43 @@ def invoke_dsc_resource(context, name_hash, props, method)
276284
data
277285
end
278286

287+
# Retries Invoke-DscResource when returned error matches error regex supplied as param.
288+
# @param context [Object] the Puppet runtime context to operate in and send feedback to
289+
# @param max_retry_count [Int] max number of times to retry Invoke-DscResource
290+
# @param retry_wait_interval_secs [Int] Time delay between retries
291+
# @param error_matcher [String] the regex pattern to match with error
292+
def retry_invoke_dsc_resource(context, max_retry_count, retry_wait_interval_secs, error_matcher)
293+
try = 0
294+
while try < max_retry_count
295+
try += 1
296+
# notify and wait for retry interval
297+
context.notice("Sleeping for #{retry_wait_interval_secs} seconds.")
298+
sleep retry_wait_interval_secs
299+
# notify and retry
300+
context.notice("Retrying: attempt #{try} of #{max_retry_count}.")
301+
data = JSON.parse(yield)
302+
# if no error, break
303+
if data['errormessage'].nil?
304+
break
305+
# check if error matches error matcher supplied
306+
elsif data['errormessage'].match?(error_matcher)
307+
# if last attempt, return error
308+
if try == max_retry_count
309+
context.notice("Attempt #{try} of #{max_retry_count} failed. No more retries.")
310+
# all attempts failed, raise error
311+
return context.err(data['errormessage'])
312+
end
313+
# if not last attempt, notify, continue and retry
314+
context.notice("Attempt #{try} of #{max_retry_count} failed.")
315+
next
316+
else
317+
# if we get an unexpected error, return
318+
return context.err(data['errormessage'])
319+
end
320+
end
321+
data
322+
end
323+
279324
# Determine if the DSC Resource is in the desired state, invoking the `Test` method unless it's
280325
# already been run for the resource, in which case reuse the result instead of checking for each
281326
# property. This behavior is only triggered if the validation_mode is set to resource; by default

spec/unit/puppet/provider/dsc_base_provider/dsc_base_provider_spec.rb

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -794,6 +794,44 @@
794794
end
795795
end
796796

797+
context 'when the invocation script errors with a collision' do
798+
it 'writes a notice via context and applies successfully on retry' do
799+
expect(ps_manager).to receive(:execute).and_return({ stdout: '{"errormessage": "The Invoke-DscResource cmdlet is in progress and must return before Invoke-DscResource can be invoked"}' })
800+
expect(context).to receive(:notice).with(/Invoke-DscResource collision detected: Please stagger the timing of your Puppet runs as this can lead to unexpected behaviour./).once
801+
expect(context).to receive(:notice).with('Sleeping for 60 seconds.').twice
802+
expect(context).to receive(:notice).with(/Retrying: attempt [1-2] of 5/).twice
803+
expect(ps_manager).to receive(:execute).and_return({ stdout: '{"errormessage": "The Invoke-DscResource cmdlet is in progress and must return before Invoke-DscResource can be invoked"}' })
804+
expect(context).to receive(:notice).with('Attempt 1 of 5 failed.')
805+
allow(provider).to receive(:sleep)
806+
expect(ps_manager).to receive(:execute).and_return({ stdout: '{"errormessage": null}' })
807+
expect { result }.not_to raise_error
808+
end
809+
810+
it 'writes a error via context and fails to apply when all retry attempts used' do
811+
expect(ps_manager).to receive(:execute).and_return({ stdout: '{"errormessage": "The Invoke-DscResource cmdlet is in progress and must return before Invoke-DscResource can be invoked"}' })
812+
.exactly(5).times
813+
expect(context).to receive(:notice).with(/Invoke-DscResource collision detected: Please stagger the timing of your Puppet runs as this can lead to unexpected behaviour./).once
814+
expect(context).to receive(:notice).with('Sleeping for 60 seconds.').exactly(5).times
815+
expect(context).to receive(:notice).with(/Retrying: attempt [1-6] of 5/).exactly(5).times
816+
expect(ps_manager).to receive(:execute).and_return({ stdout: '{"errormessage": "The Invoke-DscResource cmdlet is in progress and must return before Invoke-DscResource can be invoked"}' })
817+
expect(context).to receive(:notice).with(/Attempt [1-6] of 5 failed/).exactly(5).times
818+
expect(context).to receive(:err).with(/The Invoke-DscResource cmdlet is in progress and must return before Invoke-DscResource can be invoked/)
819+
allow(provider).to receive(:sleep)
820+
expect(result).to be_nil
821+
end
822+
823+
it 'writes an error via context and fails to apply when encountering an unexpected error' do
824+
expect(ps_manager).to receive(:execute).and_return({ stdout: '{"errormessage": "The Invoke-DscResource cmdlet is in progress and must return before Invoke-DscResource can be invoked"}' })
825+
expect(context).to receive(:notice).with(/Invoke-DscResource collision detected: Please stagger the timing of your Puppet runs as this can lead to unexpected behaviour./).once
826+
expect(context).to receive(:notice).with('Sleeping for 60 seconds.').once
827+
expect(context).to receive(:notice).with(/Retrying: attempt 1 of 5/).once
828+
allow(provider).to receive(:sleep)
829+
expect(ps_manager).to receive(:execute).and_return({ stdout: '{"errormessage": "Some unexpected error"}' }).once
830+
expect(context).to receive(:err).with(/Some unexpected error/)
831+
expect(result).to be_nil
832+
end
833+
end
834+
797835
context 'when the invocation script returns data without errors' do
798836
it 'filters for the correct properties to invoke and returns the results' do
799837
expect(ps_manager).to receive(:execute).with("Script: #{apply_props}").and_return({ stdout: '{"in_desired_state": true, "errormessage": null}' })

0 commit comments

Comments
 (0)