Skip to content

Commit 36106f9

Browse files
Merge pull request #235 from puppetlabs/CAT-2432-DSC_deferred_troubleshooting
(CAT-2432) Troubleshoot deferred resources in DSC
2 parents 5213ae0 + bd01ce6 commit 36106f9

File tree

9 files changed

+273
-27
lines changed

9 files changed

+273
-27
lines changed

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
/junit/
1616
/log/
1717
/pkg/
18-
/spec/fixtures/manifests/
1918
/spec/fixtures/modules/*
2019
/tmp/
2120
/vendor/

.rubocop_todo.yml

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
# This configuration was generated by
22
# `rubocop --auto-gen-config`
3-
# on 2025-10-01 08:51:22 UTC using RuboCop version 1.73.2.
3+
# on 2025-11-04 11:21:01 UTC using RuboCop version 1.73.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
77
# versions of RuboCop, may require this file to be generated again.
88

9+
# Offense count: 2
10+
# This cop supports safe autocorrection (--autocorrect).
11+
RSpec/ExpectActual:
12+
Exclude:
13+
- '**/spec/routing/**/*'
14+
- 'spec/acceptance/basic_functionality/deferred_spec.rb'
15+
916
# Offense count: 2
1017
# Configuration parameters: Include, CustomTransform, IgnoreMethods, IgnoreMetadata.
1118
# Include: **/*_spec.rb
@@ -20,3 +27,11 @@ RSpec/SpecFilePathFormat:
2027
Style/BitwisePredicate:
2128
Exclude:
2229
- 'lib/puppet/type/dsc.rb'
30+
31+
# Offense count: 1
32+
# This cop supports unsafe autocorrection (--autocorrect-all).
33+
# Configuration parameters: ConvertCodeThatCanStartToReturnNil, AllowedMethods, MaxChainLength.
34+
# AllowedMethods: present?, blank?, presence, try, try!
35+
Style/SafeNavigation:
36+
Exclude:
37+
- 'lib/puppet/provider/base_dsc_lite/powershell.rb'

lib/puppet/provider/base_dsc_lite/powershell.rb

Lines changed: 130 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,119 @@ def self.vendored_modules_path
4040
File.expand_path(Pathname.new(__FILE__).dirname + '../../../' + 'puppet_x/dsc_resources')
4141
end
4242

43+
def self.template_path
44+
File.expand_path(Pathname.new(__FILE__).dirname)
45+
end
46+
47+
def self.format_dsc_lite(dsc_value)
48+
PuppetX::PuppetLabs::DscLite::PowerShellHashFormatter.format(dsc_value)
49+
end
50+
51+
def self.escape_quotes(text)
52+
text.gsub("'", "''")
53+
end
54+
55+
def self.redact_content(content)
56+
# Note that here we match after an equals to ensure we redact the value being passed, but not the key.
57+
# This means a redaction of a string not including '= ' before the string value will not redact.
58+
# Every secret unwrapped in this module will unwrap as "'secret' # PuppetSensitive" and, currently,
59+
# always inside a hash table to be passed along. This means we can (currently) expect the value to
60+
# always come after an equals sign.
61+
# Note that the line may include a semi-colon and/or a newline character after the sensitive unwrap.
62+
content.gsub(%r{= '.+' # PuppetSensitive;?(\\n)?$}, "= '[REDACTED]'")
63+
end
64+
65+
# ---------------------------
66+
# Deferred value resolution - NEW APPROACH
67+
# ---------------------------
68+
69+
# Resolve deferred values in properties right before PowerShell script generation
70+
# This is the correct timing - after catalog application starts but before template rendering
71+
def resolve_deferred_values!
72+
return unless resource.parameters.key?(:properties)
73+
74+
current_properties = resource.parameters[:properties].value
75+
return unless contains_deferred_values?(current_properties)
76+
77+
Puppet.notice('DSC PROVIDER → Resolving deferred values in properties')
78+
79+
begin
80+
# Resolve deferred values directly using the properties hash
81+
resolved_properties = manually_resolve_deferred_values(current_properties)
82+
83+
# Update the resource with resolved properties
84+
resource.parameters[:properties].value = resolved_properties
85+
86+
# Verify resolution worked
87+
if contains_deferred_values?(resolved_properties)
88+
Puppet.warning('DSC PROVIDER → Some deferred values could not be resolved')
89+
else
90+
Puppet.notice('DSC PROVIDER → All deferred values resolved successfully')
91+
end
92+
rescue => e
93+
Puppet.warning("DSC PROVIDER → Error resolving deferred values: #{e.class}: #{e.message}")
94+
Puppet.debug("DSC PROVIDER → Error backtrace: #{e.backtrace.join("\n")}")
95+
# Continue with unresolved values - they will be stringified but at least won't crash
96+
end
97+
end
98+
99+
# Recursively resolve deferred values in a data structure
100+
def manually_resolve_deferred_values(value)
101+
case value
102+
when Hash
103+
resolved_hash = {}
104+
value.each do |k, v|
105+
resolved_key = manually_resolve_deferred_values(k)
106+
resolved_value = manually_resolve_deferred_values(v)
107+
resolved_hash[resolved_key] = resolved_value
108+
end
109+
resolved_hash
110+
when Array
111+
value.map { |v| manually_resolve_deferred_values(v) }
112+
else
113+
# Handle different types of deferred objects
114+
if value.is_a?(Puppet::Pops::Evaluator::DeferredValue)
115+
# DeferredValue objects have a @proc instance variable we can call
116+
proc = value.instance_variable_get(:@proc)
117+
return proc.call if proc && proc.respond_to?(:call)
118+
119+
Puppet.debug('DSC PROVIDER → DeferredValue has no callable proc')
120+
return value.to_s
121+
122+
elsif value && value.class.name.include?('Deferred')
123+
# For other Deferred types, try standard resolution
124+
if value.respond_to?(:name)
125+
begin
126+
return Puppet::Pops::Evaluator::DeferredResolver.resolve(value.name, nil, {})
127+
rescue => e
128+
Puppet.debug("DSC PROVIDER → Failed to resolve Deferred object: #{e.message}")
129+
return value.to_s
130+
end
131+
else
132+
Puppet.debug('DSC PROVIDER → Deferred object has no name method')
133+
return value.to_s
134+
end
135+
end
136+
137+
# Return the value unchanged if it's not deferred
138+
value
139+
end
140+
end
141+
142+
# Check if a value contains any deferred values (recursively)
143+
def contains_deferred_values?(value)
144+
case value
145+
when Hash
146+
value.any? { |k, v| contains_deferred_values?(k) || contains_deferred_values?(v) }
147+
when Array
148+
value.any? { |v| contains_deferred_values?(v) }
149+
else
150+
# Check if this is a Deferred object or DeferredValue
151+
value && (value.class.name.include?('Deferred') ||
152+
value.is_a?(Puppet::Pops::Evaluator::DeferredValue))
153+
end
154+
end
155+
43156
def dsc_parameters
44157
resource.parameters_with_value.select do |p|
45158
p.name.to_s.include? 'dsc_'
@@ -52,10 +165,6 @@ def dsc_property_param
52165
end
53166
end
54167

55-
def self.template_path
56-
File.expand_path(Pathname.new(__FILE__).dirname)
57-
end
58-
59168
def set_timeout
60169
resource[:dsc_timeout] ? resource[:dsc_timeout] * 1000 : 1_200_000
61170
end
@@ -65,18 +174,26 @@ def ps_manager
65174
Pwsh::Manager.instance(command(:powershell), Pwsh::Manager.powershell_args, debug: debug_output)
66175
end
67176

177+
# Keep for ERBs that call provider.format_for_ps(...)
178+
def format_for_ps(value)
179+
self.class.format_dsc_lite(value)
180+
end
181+
68182
def exists?
183+
# Resolve deferred values right before we start processing
184+
resolve_deferred_values!
185+
69186
timeout = set_timeout
70187
Puppet.debug "Dsc Timeout: #{timeout} milliseconds"
71188
version = Facter.value(:powershell_version)
72189
Puppet.debug "PowerShell Version: #{version}"
190+
73191
script_content = ps_script_content('test')
74192
Puppet.debug "\n" + self.class.redact_content(script_content)
75193

76194
if Pwsh::Manager.windows_powershell_supported?
77195
output = ps_manager.execute(script_content, timeout)
78196
raise Puppet::Error, output[:errormessage] if output[:errormessage]&.match?(%r{PowerShell module timeout \(\d+ ms\) exceeded while executing})
79-
80197
output = output[:stdout]
81198
else
82199
self.class.upgrade_message
@@ -95,15 +212,18 @@ def exists?
95212
end
96213

97214
def create
215+
# Resolve deferred values right before we start processing
216+
resolve_deferred_values!
217+
98218
timeout = set_timeout
99219
Puppet.debug "Dsc Timeout: #{timeout} milliseconds"
220+
100221
script_content = ps_script_content('set')
101222
Puppet.debug "\n" + self.class.redact_content(script_content)
102223

103224
if Pwsh::Manager.windows_powershell_supported?
104225
output = ps_manager.execute(script_content, timeout)
105226
raise Puppet::Error, output[:errormessage] if output[:errormessage]&.match?(%r{PowerShell module timeout \(\d+ ms\) exceeded while executing})
106-
107227
output = output[:stdout]
108228
else
109229
self.class.upgrade_message
@@ -114,7 +234,6 @@ def create
114234
data = JSON.parse(output)
115235

116236
raise(data['errormessage']) unless data['errormessage'].empty?
117-
118237
notify_reboot_pending if data['rebootrequired'] == true
119238

120239
data
@@ -138,24 +257,6 @@ def notify_reboot_pending
138257
end
139258
end
140259

141-
def self.format_dsc_lite(dsc_value)
142-
PuppetX::PuppetLabs::DscLite::PowerShellHashFormatter.format(dsc_value)
143-
end
144-
145-
def self.escape_quotes(text)
146-
text.gsub("'", "''")
147-
end
148-
149-
def self.redact_content(content)
150-
# Note that here we match after an equals to ensure we redact the value being passed, but not the key.
151-
# This means a redaction of a string not including '= ' before the string value will not redact.
152-
# Every secret unwrapped in this module will unwrap as "'secret' # PuppetSensitive" and, currently,
153-
# always inside a hash table to be passed along. This means we can (currently) expect the value to
154-
# always come after an equals sign.
155-
# Note that the line may include a semi-colon and/or a newline character after the sensitive unwrap.
156-
content.gsub(%r{= '.+' # PuppetSensitive;?(\\n)?$}, "= '[REDACTED]'")
157-
end
158-
159260
def ps_script_content(mode)
160261
self.class.ps_script_content(mode, resource, self)
161262
end
@@ -165,6 +266,10 @@ def self.ps_script_content(mode, resource, provider)
165266
@param_hash = resource
166267
template_name = resource.generic_dsc ? '/invoke_generic_dsc_resource.ps1.erb' : '/invoke_dsc_resource.ps1.erb'
167268
file = File.new(template_path + template_name, encoding: Encoding::UTF_8)
269+
270+
# Make vendored_modules_path visible in ERB if the template uses it
271+
vendored_modules_path = self.vendored_modules_path
272+
168273
template = ERB.new(file.read, trim_mode: '-')
169274
template.result(binding)
170275
end

lib/puppet/type/dsc.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ module => 'PSDesiredStateConfiguration',
1717
}
1818
}
1919
DOC
20+
2021
require Pathname.new(__FILE__).dirname + '../../' + 'puppet/type/base_dsc_lite'
2122
require Pathname.new(__FILE__).dirname + '../../puppet_x/puppetlabs/dsc_lite/dsc_type_helpers'
2223

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# spec/acceptance/deferred_spec.rb
2+
# frozen_string_literal: true
3+
4+
require 'spec_helper_acceptance'
5+
6+
def read_fixture(name)
7+
File.read(File.join(__dir__, '..', '..', 'fixtures', 'manifests', name))
8+
end
9+
10+
def read_win_file_if_exists(path)
11+
# Use a script block with literals; avoid $variables to prevent transport/quoting expansion
12+
# Also keep exit 0 regardless of existence so run_shell doesn't raise.
13+
ps = %{& { if (Test-Path -LiteralPath '#{path}') { Get-Content -Raw -LiteralPath '#{path}' } else { '<<<FILE_NOT_FOUND>>>' } } }
14+
r = run_shell(%(powershell.exe -NoProfile -NonInteractive -Command "#{ps}"))
15+
body = (r.stdout || '').to_s
16+
exists = !body.include?('<<<FILE_NOT_FOUND>>>')
17+
{ exists: exists, content: exists ? body : '' }
18+
end
19+
20+
describe 'deferred values with dsc_lite' do
21+
let(:control_manifest) { read_fixture('01_file_deferred.pp') }
22+
let(:dsc_control_manifest_epp) { read_fixture('01b_file_deferred_with_epp.pp') }
23+
let(:dsc_deferred_direct) { read_fixture('02_dsc_deferred_direct.pp') }
24+
let(:dsc_deferred_inline) { read_fixture('02b_dsc_deferred_inline.pp') }
25+
26+
it 'control (01): native file + Deferred resolves to hello-file' do
27+
result = idempotent_apply(control_manifest)
28+
expect(result.exit_code).to eq(0)
29+
out = read_win_file_if_exists('C:/Temp/deferred_ok.txt')
30+
expect(out[:exists]).to be(true)
31+
expect(out[:content].strip).to eq('hello-file')
32+
end
33+
34+
it 'control (01b): native file + Deferred resolves to hello-file (EPP)' do
35+
result = idempotent_apply(dsc_control_manifest_epp)
36+
expect(result.exit_code).to eq(0)
37+
out = read_win_file_if_exists('C:/Temp/deferred_ok.txt')
38+
expect(out[:exists]).to be(true)
39+
expect(out[:content].strip).to eq('hello-file')
40+
end
41+
42+
it '02: passing Deferred via variable to DSC resolves to hello-dsc (otherwise flag bug)' do
43+
apply = apply_manifest(dsc_deferred_direct)
44+
out = read_win_file_if_exists('C:/Temp/from_dsc.txt')
45+
content = out[:content].strip
46+
if out[:exists] && content == 'hello-dsc'
47+
expect(true).to be(true)
48+
elsif out[:exists] && content =~ %r{Deferred\s*\(|Puppet::Pops::Types::Deferred}i
49+
raise "BUG: 02 wrote stringified Deferred: #{content.inspect}\nApply:\n#{apply.stdout}#{apply.stderr}"
50+
else
51+
raise "Unexpected 02 outcome. Exists=#{out[:exists]} Content=#{content.inspect}\nApply:\n#{apply.stdout}#{apply.stderr}"
52+
end
53+
end
54+
55+
# NEW 02b: inline Deferred on the DSC property (no variable intermediary)
56+
it '02b: passing Deferred inline to DSC resolves to hello-dsc-inline (otherwise flag bug)' do
57+
apply = apply_manifest(dsc_deferred_inline)
58+
out = read_win_file_if_exists('C:/Temp/from_dsc_inline.txt')
59+
content = out[:content].strip
60+
if out[:exists] && content == 'hello-dsc-inline'
61+
expect(true).to be(true)
62+
elsif out[:exists] && content =~ %r{Deferred\s*\(|Puppet::Pops::Types::Deferred}i
63+
raise "BUG: 02b wrote stringified Deferred: #{content.inspect}\nApply:\n#{apply.stdout}#{apply.stderr}"
64+
else
65+
raise "Unexpected 02b outcome. Exists=#{out[:exists]} Content=#{content.inspect}\nApply:\n#{apply.stdout}#{apply.stderr}"
66+
end
67+
end
68+
end
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# spec/fixtures/manifests/01_file_deferred.pp
2+
file { 'C:/Temp':
3+
ensure => directory,
4+
}
5+
6+
$deferred = Deferred('join', [['hello','-','file'], ''])
7+
8+
file { 'C:/Temp/deferred_ok.txt':
9+
ensure => file,
10+
content => $deferred,
11+
require => File['C:/Temp'],
12+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# spec/fixtures/manifests/01_file_deferred.pp
2+
file { 'C:/Temp':
3+
ensure => directory,
4+
}
5+
6+
$deferred = Deferred('join', [['hello','-','file'], ''])
7+
8+
file { 'C:/Temp/deferred_ok.txt':
9+
ensure => file,
10+
content => Deferred('inline_epp', ['<%= $content.unwrap %>', { content => $deferred }]),
11+
require => File['C:/Temp'],
12+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# spec/fixtures/manifests/02_dsc_deferred_direct.pp
2+
file { 'C:/Temp':
3+
ensure => directory,
4+
}
5+
6+
$deferred = Deferred('join', [['hello','-','dsc'], ''])
7+
8+
dsc { 'WriteFileViaDSC':
9+
resource_name => 'File',
10+
module => 'PSDesiredStateConfiguration',
11+
properties => {
12+
'DestinationPath' => 'C:\Temp\from_dsc.txt',
13+
'Type' => 'File',
14+
'Ensure' => 'Present',
15+
'Contents' => $deferred,
16+
},
17+
require => File['C:/Temp'],
18+
}

0 commit comments

Comments
 (0)