diff --git a/.gitignore b/.gitignore index 2803e566..af66731a 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,6 @@ /junit/ /log/ /pkg/ -/spec/fixtures/manifests/ /spec/fixtures/modules/* /tmp/ /vendor/ diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 03a9e5c4..6034c21d 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,11 +1,18 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2025-10-01 08:51:22 UTC using RuboCop version 1.73.2. +# on 2025-11-04 11:21:01 UTC using RuboCop version 1.73.2. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +RSpec/ExpectActual: + Exclude: + - '**/spec/routing/**/*' + - 'spec/acceptance/basic_functionality/deferred_spec.rb' + # Offense count: 2 # Configuration parameters: Include, CustomTransform, IgnoreMethods, IgnoreMetadata. # Include: **/*_spec.rb @@ -20,3 +27,11 @@ RSpec/SpecFilePathFormat: Style/BitwisePredicate: Exclude: - 'lib/puppet/type/dsc.rb' + +# Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: ConvertCodeThatCanStartToReturnNil, AllowedMethods, MaxChainLength. +# AllowedMethods: present?, blank?, presence, try, try! +Style/SafeNavigation: + Exclude: + - 'lib/puppet/provider/base_dsc_lite/powershell.rb' diff --git a/lib/puppet/provider/base_dsc_lite/powershell.rb b/lib/puppet/provider/base_dsc_lite/powershell.rb index c0122c33..53b1755c 100644 --- a/lib/puppet/provider/base_dsc_lite/powershell.rb +++ b/lib/puppet/provider/base_dsc_lite/powershell.rb @@ -40,6 +40,119 @@ def self.vendored_modules_path File.expand_path(Pathname.new(__FILE__).dirname + '../../../' + 'puppet_x/dsc_resources') end + def self.template_path + File.expand_path(Pathname.new(__FILE__).dirname) + end + + def self.format_dsc_lite(dsc_value) + PuppetX::PuppetLabs::DscLite::PowerShellHashFormatter.format(dsc_value) + end + + def self.escape_quotes(text) + text.gsub("'", "''") + end + + def self.redact_content(content) + # Note that here we match after an equals to ensure we redact the value being passed, but not the key. + # This means a redaction of a string not including '= ' before the string value will not redact. + # Every secret unwrapped in this module will unwrap as "'secret' # PuppetSensitive" and, currently, + # always inside a hash table to be passed along. This means we can (currently) expect the value to + # always come after an equals sign. + # Note that the line may include a semi-colon and/or a newline character after the sensitive unwrap. + content.gsub(%r{= '.+' # PuppetSensitive;?(\\n)?$}, "= '[REDACTED]'") + end + + # --------------------------- + # Deferred value resolution - NEW APPROACH + # --------------------------- + + # Resolve deferred values in properties right before PowerShell script generation + # This is the correct timing - after catalog application starts but before template rendering + def resolve_deferred_values! + return unless resource.parameters.key?(:properties) + + current_properties = resource.parameters[:properties].value + return unless contains_deferred_values?(current_properties) + + Puppet.notice('DSC PROVIDER → Resolving deferred values in properties') + + begin + # Resolve deferred values directly using the properties hash + resolved_properties = manually_resolve_deferred_values(current_properties) + + # Update the resource with resolved properties + resource.parameters[:properties].value = resolved_properties + + # Verify resolution worked + if contains_deferred_values?(resolved_properties) + Puppet.warning('DSC PROVIDER → Some deferred values could not be resolved') + else + Puppet.notice('DSC PROVIDER → All deferred values resolved successfully') + end + rescue => e + Puppet.warning("DSC PROVIDER → Error resolving deferred values: #{e.class}: #{e.message}") + Puppet.debug("DSC PROVIDER → Error backtrace: #{e.backtrace.join("\n")}") + # Continue with unresolved values - they will be stringified but at least won't crash + end + end + + # Recursively resolve deferred values in a data structure + def manually_resolve_deferred_values(value) + case value + when Hash + resolved_hash = {} + value.each do |k, v| + resolved_key = manually_resolve_deferred_values(k) + resolved_value = manually_resolve_deferred_values(v) + resolved_hash[resolved_key] = resolved_value + end + resolved_hash + when Array + value.map { |v| manually_resolve_deferred_values(v) } + else + # Handle different types of deferred objects + if value.is_a?(Puppet::Pops::Evaluator::DeferredValue) + # DeferredValue objects have a @proc instance variable we can call + proc = value.instance_variable_get(:@proc) + return proc.call if proc && proc.respond_to?(:call) + + Puppet.debug('DSC PROVIDER → DeferredValue has no callable proc') + return value.to_s + + elsif value && value.class.name.include?('Deferred') + # For other Deferred types, try standard resolution + if value.respond_to?(:name) + begin + return Puppet::Pops::Evaluator::DeferredResolver.resolve(value.name, nil, {}) + rescue => e + Puppet.debug("DSC PROVIDER → Failed to resolve Deferred object: #{e.message}") + return value.to_s + end + else + Puppet.debug('DSC PROVIDER → Deferred object has no name method') + return value.to_s + end + end + + # Return the value unchanged if it's not deferred + value + end + end + + # Check if a value contains any deferred values (recursively) + def contains_deferred_values?(value) + case value + when Hash + value.any? { |k, v| contains_deferred_values?(k) || contains_deferred_values?(v) } + when Array + value.any? { |v| contains_deferred_values?(v) } + else + # Check if this is a Deferred object or DeferredValue + value && (value.class.name.include?('Deferred') || + value.is_a?(Puppet::Pops::Evaluator::DeferredValue)) + end + end + def dsc_parameters resource.parameters_with_value.select do |p| p.name.to_s.include? 'dsc_' @@ -52,10 +165,6 @@ def dsc_property_param end end - def self.template_path - File.expand_path(Pathname.new(__FILE__).dirname) - end - def set_timeout resource[:dsc_timeout] ? resource[:dsc_timeout] * 1000 : 1_200_000 end @@ -65,18 +174,26 @@ def ps_manager Pwsh::Manager.instance(command(:powershell), Pwsh::Manager.powershell_args, debug: debug_output) end + # Keep for ERBs that call provider.format_for_ps(...) + def format_for_ps(value) + self.class.format_dsc_lite(value) + end + def exists? + # Resolve deferred values right before we start processing + resolve_deferred_values! + timeout = set_timeout Puppet.debug "Dsc Timeout: #{timeout} milliseconds" version = Facter.value(:powershell_version) Puppet.debug "PowerShell Version: #{version}" + script_content = ps_script_content('test') Puppet.debug "\n" + self.class.redact_content(script_content) if Pwsh::Manager.windows_powershell_supported? output = ps_manager.execute(script_content, timeout) raise Puppet::Error, output[:errormessage] if output[:errormessage]&.match?(%r{PowerShell module timeout \(\d+ ms\) exceeded while executing}) - output = output[:stdout] else self.class.upgrade_message @@ -95,15 +212,18 @@ def exists? end def create + # Resolve deferred values right before we start processing + resolve_deferred_values! + timeout = set_timeout Puppet.debug "Dsc Timeout: #{timeout} milliseconds" + script_content = ps_script_content('set') Puppet.debug "\n" + self.class.redact_content(script_content) if Pwsh::Manager.windows_powershell_supported? output = ps_manager.execute(script_content, timeout) raise Puppet::Error, output[:errormessage] if output[:errormessage]&.match?(%r{PowerShell module timeout \(\d+ ms\) exceeded while executing}) - output = output[:stdout] else self.class.upgrade_message @@ -114,7 +234,6 @@ def create data = JSON.parse(output) raise(data['errormessage']) unless data['errormessage'].empty? - notify_reboot_pending if data['rebootrequired'] == true data @@ -138,24 +257,6 @@ def notify_reboot_pending end end - def self.format_dsc_lite(dsc_value) - PuppetX::PuppetLabs::DscLite::PowerShellHashFormatter.format(dsc_value) - end - - def self.escape_quotes(text) - text.gsub("'", "''") - end - - def self.redact_content(content) - # Note that here we match after an equals to ensure we redact the value being passed, but not the key. - # This means a redaction of a string not including '= ' before the string value will not redact. - # Every secret unwrapped in this module will unwrap as "'secret' # PuppetSensitive" and, currently, - # always inside a hash table to be passed along. This means we can (currently) expect the value to - # always come after an equals sign. - # Note that the line may include a semi-colon and/or a newline character after the sensitive unwrap. - content.gsub(%r{= '.+' # PuppetSensitive;?(\\n)?$}, "= '[REDACTED]'") - end - def ps_script_content(mode) self.class.ps_script_content(mode, resource, self) end @@ -165,6 +266,10 @@ def self.ps_script_content(mode, resource, provider) @param_hash = resource template_name = resource.generic_dsc ? '/invoke_generic_dsc_resource.ps1.erb' : '/invoke_dsc_resource.ps1.erb' file = File.new(template_path + template_name, encoding: Encoding::UTF_8) + + # Make vendored_modules_path visible in ERB if the template uses it + vendored_modules_path = self.vendored_modules_path + template = ERB.new(file.read, trim_mode: '-') template.result(binding) end diff --git a/lib/puppet/type/dsc.rb b/lib/puppet/type/dsc.rb index c72d0937..9ecdd595 100644 --- a/lib/puppet/type/dsc.rb +++ b/lib/puppet/type/dsc.rb @@ -17,6 +17,7 @@ module => 'PSDesiredStateConfiguration', } } DOC + require Pathname.new(__FILE__).dirname + '../../' + 'puppet/type/base_dsc_lite' require Pathname.new(__FILE__).dirname + '../../puppet_x/puppetlabs/dsc_lite/dsc_type_helpers' diff --git a/spec/acceptance/basic_functionality/deferred_spec.rb b/spec/acceptance/basic_functionality/deferred_spec.rb new file mode 100644 index 00000000..9ca6175b --- /dev/null +++ b/spec/acceptance/basic_functionality/deferred_spec.rb @@ -0,0 +1,68 @@ +# spec/acceptance/deferred_spec.rb +# frozen_string_literal: true + +require 'spec_helper_acceptance' + +def read_fixture(name) + File.read(File.join(__dir__, '..', '..', 'fixtures', 'manifests', name)) +end + +def read_win_file_if_exists(path) + # Use a script block with literals; avoid $variables to prevent transport/quoting expansion + # Also keep exit 0 regardless of existence so run_shell doesn't raise. + ps = %{& { if (Test-Path -LiteralPath '#{path}') { Get-Content -Raw -LiteralPath '#{path}' } else { '<<>>' } } } + r = run_shell(%(powershell.exe -NoProfile -NonInteractive -Command "#{ps}")) + body = (r.stdout || '').to_s + exists = !body.include?('<<>>') + { exists: exists, content: exists ? body : '' } +end + +describe 'deferred values with dsc_lite' do + let(:control_manifest) { read_fixture('01_file_deferred.pp') } + let(:dsc_control_manifest_epp) { read_fixture('01b_file_deferred_with_epp.pp') } + let(:dsc_deferred_direct) { read_fixture('02_dsc_deferred_direct.pp') } + let(:dsc_deferred_inline) { read_fixture('02b_dsc_deferred_inline.pp') } + + it 'control (01): native file + Deferred resolves to hello-file' do + result = idempotent_apply(control_manifest) + expect(result.exit_code).to eq(0) + out = read_win_file_if_exists('C:/Temp/deferred_ok.txt') + expect(out[:exists]).to be(true) + expect(out[:content].strip).to eq('hello-file') + end + + it 'control (01b): native file + Deferred resolves to hello-file (EPP)' do + result = idempotent_apply(dsc_control_manifest_epp) + expect(result.exit_code).to eq(0) + out = read_win_file_if_exists('C:/Temp/deferred_ok.txt') + expect(out[:exists]).to be(true) + expect(out[:content].strip).to eq('hello-file') + end + + it '02: passing Deferred via variable to DSC resolves to hello-dsc (otherwise flag bug)' do + apply = apply_manifest(dsc_deferred_direct) + out = read_win_file_if_exists('C:/Temp/from_dsc.txt') + content = out[:content].strip + if out[:exists] && content == 'hello-dsc' + expect(true).to be(true) + elsif out[:exists] && content =~ %r{Deferred\s*\(|Puppet::Pops::Types::Deferred}i + raise "BUG: 02 wrote stringified Deferred: #{content.inspect}\nApply:\n#{apply.stdout}#{apply.stderr}" + else + raise "Unexpected 02 outcome. Exists=#{out[:exists]} Content=#{content.inspect}\nApply:\n#{apply.stdout}#{apply.stderr}" + end + end + + # NEW 02b: inline Deferred on the DSC property (no variable intermediary) + it '02b: passing Deferred inline to DSC resolves to hello-dsc-inline (otherwise flag bug)' do + apply = apply_manifest(dsc_deferred_inline) + out = read_win_file_if_exists('C:/Temp/from_dsc_inline.txt') + content = out[:content].strip + if out[:exists] && content == 'hello-dsc-inline' + expect(true).to be(true) + elsif out[:exists] && content =~ %r{Deferred\s*\(|Puppet::Pops::Types::Deferred}i + raise "BUG: 02b wrote stringified Deferred: #{content.inspect}\nApply:\n#{apply.stdout}#{apply.stderr}" + else + raise "Unexpected 02b outcome. Exists=#{out[:exists]} Content=#{content.inspect}\nApply:\n#{apply.stdout}#{apply.stderr}" + end + end +end diff --git a/spec/fixtures/manifests/01_file_deferred.pp b/spec/fixtures/manifests/01_file_deferred.pp new file mode 100644 index 00000000..4f12b74e --- /dev/null +++ b/spec/fixtures/manifests/01_file_deferred.pp @@ -0,0 +1,12 @@ +# spec/fixtures/manifests/01_file_deferred.pp +file { 'C:/Temp': + ensure => directory, +} + +$deferred = Deferred('join', [['hello','-','file'], '']) + +file { 'C:/Temp/deferred_ok.txt': + ensure => file, + content => $deferred, + require => File['C:/Temp'], +} diff --git a/spec/fixtures/manifests/01b_file_deferred_with_epp.pp b/spec/fixtures/manifests/01b_file_deferred_with_epp.pp new file mode 100644 index 00000000..9f9dfe2c --- /dev/null +++ b/spec/fixtures/manifests/01b_file_deferred_with_epp.pp @@ -0,0 +1,12 @@ +# spec/fixtures/manifests/01_file_deferred.pp +file { 'C:/Temp': + ensure => directory, +} + +$deferred = Deferred('join', [['hello','-','file'], '']) + +file { 'C:/Temp/deferred_ok.txt': + ensure => file, + content => Deferred('inline_epp', ['<%= $content.unwrap %>', { content => $deferred }]), + require => File['C:/Temp'], +} diff --git a/spec/fixtures/manifests/02_dsc_deferred_direct.pp b/spec/fixtures/manifests/02_dsc_deferred_direct.pp new file mode 100644 index 00000000..3fac8289 --- /dev/null +++ b/spec/fixtures/manifests/02_dsc_deferred_direct.pp @@ -0,0 +1,18 @@ +# spec/fixtures/manifests/02_dsc_deferred_direct.pp +file { 'C:/Temp': + ensure => directory, +} + +$deferred = Deferred('join', [['hello','-','dsc'], '']) + +dsc { 'WriteFileViaDSC': + resource_name => 'File', + module => 'PSDesiredStateConfiguration', + properties => { + 'DestinationPath' => 'C:\Temp\from_dsc.txt', + 'Type' => 'File', + 'Ensure' => 'Present', + 'Contents' => $deferred, + }, + require => File['C:/Temp'], +} diff --git a/spec/fixtures/manifests/02b_dsc_deferred_inline.pp b/spec/fixtures/manifests/02b_dsc_deferred_inline.pp new file mode 100644 index 00000000..6acd41f7 --- /dev/null +++ b/spec/fixtures/manifests/02b_dsc_deferred_inline.pp @@ -0,0 +1,16 @@ +# spec/fixtures/manifests/02b_dsc_deferred_inline.pp +file { 'C:/Temp': + ensure => directory, +} + +dsc { 'WriteFileViaDSCInline': + resource_name => 'File', + module => 'PSDesiredStateConfiguration', + properties => { + 'DestinationPath' => 'C:\Temp\from_dsc_inline.txt', + 'Type' => 'File', + 'Ensure' => 'Present', + 'Contents' => Deferred('join', [['hello','-','dsc-inline'], '']), + }, + require => File['C:/Temp'], +}