Class: Kitchen::Provisioner::Dsc

Inherits:
Base
  • Object
show all
Defined in:
lib/kitchen/provisioner/dsc.rb

Overview

Applies PowerShell Desired State Configuration to a Test Kitchen instance.

The provisioner runs across the four Test Kitchen phases:

  1. #install_command configures the Local Configuration Manager on the system under test.
  2. #init_command creates the remote configuration directory and, on WMF 5, installs any modules requested from a PowerShell gallery.
  3. #create_sandbox stages DSC resources and the configuration script on the workstation, ready for upload.
  4. #prepare_command compiles the configuration into a MOF on the system under test and #run_command applies it.

Two project layouts are supported, chosen automatically by #powershell_module?: module style, where the kitchen root is itself a PowerShell module (identified by a <module name>.psd1 manifest), and repository style, where DSC resources live in a modules directory.

Examples:

Minimal kitchen.yml

provisioner:
  name: dsc
  dsc_local_configuration_manager_version: wmf5
  configuration_script: web.ps1

See Also:

Instance Attribute Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#tmp_dirString?

Returns path to a scratch directory on the system under test. Set by consumers that need somewhere to write intermediate files; unused by the provisioner itself.

Returns:

  • (String, nil)

    path to a scratch directory on the system under test. Set by consumers that need somewhere to write intermediate files; unused by the provisioner itself.



53
54
55
# File 'lib/kitchen/provisioner/dsc.rb', line 53

def tmp_dir
  @tmp_dir
end

Instance Method Details

#configuration_data_assignmentString (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

PowerShell that assigns :configuration_data to its variable.

Returns:

  • (String)

    a hashtable assignment



399
400
401
# File 'lib/kitchen/provisioner/dsc.rb', line 399

def configuration_data_assignment
  "$" + configuration_data_variable + " = " + ps_hash(config[:configuration_data])
end

#configuration_data_variableString (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Name of the PowerShell variable holding configuration data.

Returns:

  • (String)

    :configuration_data_variable, or ConfigurationData when it was explicitly blanked out



391
392
393
# File 'lib/kitchen/provisioner/dsc.rb', line 391

def configuration_data_variable
  config[:configuration_data_variable].nil? ? "ConfigurationData" : config[:configuration_data_variable]
end

#create_sandboxvoid

This method returns an undefined value.

Stages DSC resources and the configuration script into the sandbox.

The sandbox is the local directory Test Kitchen uploads to the system under test. Which staging strategy runs depends on whether the project is laid out as a PowerShell module or as a repository of modules.

Raises:

  • (Errno::ENOENT)

    if the configuration script named by :configuration_script_folder and :configuration_script is missing

See Also:



134
135
136
137
138
139
140
141
142
143
144
# File 'lib/kitchen/provisioner/dsc.rb', line 134

def create_sandbox
  super
  info("Staging DSC Resource Modules for copy to the SUT")
  if powershell_module?
    prepare_resource_style_directory
  else
    prepare_repo_style_directory
  end
  info("Staging DSC configuration script for copy to the SUT")
  prepare_configuration_script
end

#ensure_array(thing) ⇒ Array (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Wraps a scalar in an array so :configuration_name may be given either as a single name or as a list.

Parameters:

  • thing (Object, Array)

    the value to normalize

Returns:

  • (Array)

    thing if it is already an array, otherwise [thing]



561
562
563
564
565
566
567
# File 'lib/kitchen/provisioner/dsc.rb', line 561

def ensure_array(thing)
  if thing.is_a?(Array)
    thing
  else
    [thing]
  end
end

#finalize_config!(instance) ⇒ self

Resolves the Local Configuration Manager settings before the instance is used.

Replaces the caller's partial :dsc_local_configuration_manager hash with the fully defaulted settings for the configured WMF version, so later phases and kitchen diagnose see the values that will actually be applied.

Parameters:

  • instance (Kitchen::Instance)

    the instance this provisioner serves

Returns:

  • (self)


83
84
85
86
# File 'lib/kitchen/provisioner/dsc.rb', line 83

def finalize_config!(instance)
  config[:dsc_local_configuration_manager] = lcm.lcm_config
  super(instance)
end

#init_commandString

Builds the command that prepares the system under test for upload.

Always creates the directory the configuration script will be copied into. On WMF 5 with :modules_from_gallery set, it also bootstraps PackageManagement and installs those modules.

Returns:

  • (String)

    PowerShell run during the converge phase, before files are transferred



115
116
117
118
119
120
121
# File 'lib/kitchen/provisioner/dsc.rb', line 115

def init_command
  script = <<~EOH
    #{setup_config_directory_script}
    #{install_module_script if install_modules?}
  EOH
  wrap_powershell_code(script)
end

#install_commandString

Builds the command that configures the Local Configuration Manager.

Runs during Test Kitchen's install phase, before any configuration is compiled, since the LCM controls how DSC behaves for the rest of the run.

Returns:

  • (String)

    PowerShell that declares and applies the SetupLCM meta-configuration



96
97
98
99
100
101
102
103
104
105
# File 'lib/kitchen/provisioner/dsc.rb', line 96

def install_command
  full_lcm_configuration_script = <<-EOH
  #{lcm.lcm_configuration_script}

  $null = SetupLCM
  Set-DscLocalConfigurationManager -Path ./SetupLCM | out-null
  EOH

  wrap_powershell_code(full_lcm_configuration_script)
end

#install_module_scriptString? (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

PowerShell that installs every requested gallery module.

Returns:

  • (String, nil)

    the bootstrap, registration and install commands, or nil when no gallery modules are configured



365
366
367
368
369
370
371
372
373
# File 'lib/kitchen/provisioner/dsc.rb', line 365

def install_module_script
  return if config[:modules_from_gallery].nil?

  <<-EOH
  #{nuget_force_bootstrap}
  #{register_psmodule_repository}
  #{powershell_modules.join("\n")}
  EOH
end

#install_modules?Boolean (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Whether gallery modules should be installed during #init_command.

Gallery installation depends on PowerShellGet, which ships with WMF 5.

Returns:

  • (Boolean)

    true only when targeting WMF 5 with modules requested



381
382
383
384
# File 'lib/kitchen/provisioner/dsc.rb', line 381

def install_modules?
  config[:dsc_local_configuration_manager_version] == "wmf5" &&
    !config[:modules_from_gallery].nil?
end

#lcmDscLcmConfiguration::LcmBase (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

The Local Configuration Manager configuration for the target WMF version.

Note that DscLcmConfiguration::Factory only recognizes "4", "wmf4_with_update", "5" and "wmf5"; every other value, including this provisioner's own "wmf4" default, yields the base LCM configuration.

Returns:

  • (DscLcmConfiguration::LcmBase)

    a memoized LCM configuration



265
266
267
268
269
270
271
# File 'lib/kitchen/provisioner/dsc.rb', line 265

def lcm
  @lcm ||= begin
    lcm_version = config[:dsc_local_configuration_manager_version]
    lcm_config = config[:dsc_local_configuration_manager]
    DscLcmConfiguration::Factory.create(lcm_version, lcm_config)
  end
end

#list_files(path) ⇒ Array<String> (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Lists the files to stage from a module-style project.

Directories are excluded because the copy recreates them as needed, and repository housekeeping files are excluded because they are not part of the module.

Parameters:

  • path (String)

    directory to enumerate

Returns:

  • (Array<String>)

    absolute paths of files to stage



436
437
438
439
440
441
442
443
444
445
446
# File 'lib/kitchen/provisioner/dsc.rb', line 436

def list_files(path)
  base_directory_content = Dir.glob(File.join(path, "*"))
  nested_directory_content = Dir.glob(File.join(path, "*/**/*"))
  all_directory_content = [base_directory_content, nested_directory_content].flatten

  ignore_files = ["Gemfile", "Gemfile.lock", "README.md", "LICENSE.txt"]
  all_directory_content.reject do |f|
    debug("Enumerating #{f}")
    ignore_files.include?(File.basename(f)) || File.directory?(f)
  end
end

#module_nameString (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

The PowerShell module name implied by the project directory.

Returns:

  • (String)

    basename of :kitchen_root



452
453
454
# File 'lib/kitchen/provisioner/dsc.rb', line 452

def module_name
  File.basename(config[:kitchen_root])
end

#nuget_force_bootstrapString? (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

PowerShell that bootstraps the NuGet package provider.

PackageManagement cannot install from a gallery until the NuGet provider is present, and its interactive bootstrap prompt would hang a converge.

Returns:

  • (String, nil)

    the bootstrap command, or nil when :nuget_force_bootstrap is disabled



328
329
330
331
332
333
# File 'lib/kitchen/provisioner/dsc.rb', line 328

def nuget_force_bootstrap
  return unless config[:nuget_force_bootstrap]

  info("Bootstrapping the nuget package provider for PowerShell PackageManagement.")
  "install-packageprovider nuget -force -forcebootstrap | out-null"
end

#pad(depth = 0) ⇒ String (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Indentation used when rendering PowerShell hashtables.

Parameters:

  • depth (Integer) (defaults to: 0)

    number of spaces

Returns:

  • (String)

    a run of spaces



510
511
512
# File 'lib/kitchen/provisioner/dsc.rb', line 510

def pad(depth = 0)
  " " * depth
end

#powershell_module?Boolean (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Whether the kitchen root is itself a PowerShell module.

Detected by a <module name>.psd1 manifest sitting beside the project, which selects module-style staging over repository-style staging.

Returns:

  • (Boolean)

    true when a matching module manifest exists



422
423
424
425
# File 'lib/kitchen/provisioner/dsc.rb', line 422

def powershell_module?
   = File.join(config[:kitchen_root], "#{module_name}.psd1")
  File.exist?()
end

#powershell_module_params(module_specification_hash) ⇒ String (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Renders a module specification hash as install-module parameters.

A Force key is dropped because -force is already appended to every install-module call, and PowerShell rejects a duplicated parameter. A Repository key is added from the configured gallery unless the caller supplied one.

Parameters:

  • module_specification_hash (Hash)

    install-module parameters, as given in kitchen.yml

Returns:

  • (String)

    space-separated -Key Value pairs



293
294
295
296
297
298
299
300
# File 'lib/kitchen/provisioner/dsc.rb', line 293

def powershell_module_params(module_specification_hash)
  keys = module_specification_hash.keys.reject { |k| k.to_s.casecmp("force") == 0 }
  unless keys.any? { |k| k.to_s.downcase == "repository" }
    keys.push(:repository)
    module_specification_hash[:repository] = psmodule_repository_name
  end
  keys.map { |key| "-#{key} #{module_specification_hash[key]}" }.join(" ")
end

#powershell_modulesArray<String> (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Builds one install-module line per entry in :modules_from_gallery.

Entries may be plain module names or hashes of install-module parameters.

Returns:

  • (Array<String>)

    PowerShell install-module invocations



309
310
311
312
313
314
315
316
317
318
# File 'lib/kitchen/provisioner/dsc.rb', line 309

def powershell_modules
  Array(config[:modules_from_gallery]).map do |powershell_module|
    params = if powershell_module.is_a? Hash
               powershell_module_params(powershell_module)
             else
               "-name '#{powershell_module}' -Repository #{psmodule_repository_name}"
             end
    "install-module #{params} -force | out-null"
  end
end

#prepare_commandString

Builds the command that compiles configurations into MOF documents.

Copies the uploaded modules onto the PSModulePath, loads the configuration script, then compiles each name in :configuration_name into c:/configurations/<name>. Any leftover MOF from a previous converge is removed first so a failed compile cannot be silently applied.

Returns:

  • (String)

    PowerShell run after files are transferred and before #run_command



155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
# File 'lib/kitchen/provisioner/dsc.rb', line 155

def prepare_command
  info("Moving DSC Resources onto PSModulePath")
  # +@ makes an explicitly mutable buffer under frozen_string_literal.
  scripts = +<<-EOH

  if (Test-Path (join-path #{config[:root_path]} 'modules'))
  {
    dir ( join-path #{config[:root_path]} 'modules/*') -directory |
    copy-item -destination $env:programfiles/windowspowershell/modules/ -recurse -force
  }

  $ConfigurationScriptPath = Join-path #{config[:root_path]} #{sandboxed_configuration_script}
  if (-not (test-path $ConfigurationScriptPath))
  {
    throw "Failed to find $ConfigurationScriptPath"
  }
  invoke-expression (get-content $ConfigurationScriptPath -raw)

  EOH
  ensure_array(config[:configuration_name]).each do |configuration|
    info("Generating the MOF script for the configuration #{configuration}")
    stage_resources_and_generate_mof_script = <<-EOH

      if(Test-Path c:/configurations/#{configuration})
      {
          Remove-Item -Recurse -Force c:/configurations/#{configuration}
      }

      $Error.clear()

      if (-not (test-path 'c:/configurations'))
      {
        mkdir 'c:/configurations' | out-null
      }

      if (-not (get-command #{configuration}))
      {
        throw "Failed to create a configuration command #{configuration}"
      }

      #{configuration_data_assignment unless config[:configuration_data].nil?}

      try{
        $null = #{configuration} -outputpath c:/configurations/#{configuration} #{"-configurationdata $" + configuration_data_variable}
      }
      catch{
      }

      if($Error -ne $null)
      {
        $Error[-1]
        exit 1
      }

    EOH
    scripts << stage_resources_and_generate_mof_script
  end
  debug("Shelling out: #{scripts}")
  wrap_powershell_code(scripts)
end

#prepare_configuration_scriptvoid (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Copies the DSC configuration script into the sandbox.

Raises:

  • (Errno::ENOENT)

    if the configured script does not exist



546
547
548
549
550
551
552
553
# File 'lib/kitchen/provisioner/dsc.rb', line 546

def prepare_configuration_script
  configuration_script_file = File.join(config[:configuration_script_folder], config[:configuration_script])
  configuration_script_path = File.join(config[:kitchen_root], configuration_script_file)
  sandbox_configuration_script_path = File.join(sandbox_path, sandboxed_configuration_script)
  FileUtils.mkdir_p(File.dirname(sandbox_configuration_script_path))
  debug("Moving #{configuration_script_path} to #{sandbox_configuration_script_path}")
  FileUtils.cp(configuration_script_path, sandbox_configuration_script_path)
end

#prepare_repo_style_directoryvoid (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Stages a repository-style project into the sandbox.

A missing modules directory is not an error: a project may ship only a configuration script and rely on resources already present on the node.



484
485
486
487
488
489
490
491
492
493
494
# File 'lib/kitchen/provisioner/dsc.rb', line 484

def prepare_repo_style_directory
  module_path = File.join(config[:kitchen_root], config[:modules_path])
  sandbox_module_path = File.join(sandbox_path, "modules")

  if Dir.exist?(module_path)
    debug("Moving #{module_path} to #{sandbox_module_path}")
    FileUtils.cp_r(module_path, sandbox_module_path)
  else
    debug("The modules path #{module_path} was not found. Not moving to #{sandbox_module_path}.")
  end
end

#prepare_resource_style_directoryvoid (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Stages a module-style project into the sandbox.

The whole kitchen root is copied to modules/<module name> so the module lands on the system under test's PSModulePath under the name DSC expects.



464
465
466
467
468
469
470
471
472
473
474
475
# File 'lib/kitchen/provisioner/dsc.rb', line 464

def prepare_resource_style_directory
  sandbox_base_module_path = File.join(sandbox_path, "modules/#{module_name}")

  base = config[:kitchen_root]
  list_files(base).each do |src|
    dest = File.join(sandbox_base_module_path, src.sub("#{base}/", ""))
    FileUtils.mkdir_p(File.dirname(dest))
    debug("Staging #{src} ")
    debug("  at #{dest}")
    FileUtils.cp(src, dest, preserve: true)
  end
end

#ps_hash(obj, depth = 0) ⇒ String (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Renders a Ruby object as a PowerShell literal.

Hashes become hashtables and arrays become arrays; every other value is rendered as a double-quoted string, so Ruby booleans and integers reach DSC quoted.

Examples:

ps_hash("AllNodes" => [{ "NodeName" => "*" }])
#=> %{@{\n  "AllNodes" =   @(\n@{\n        "NodeName" = "*"\n      }\n)\n}}

Parameters:

  • obj (Hash, Array, Object)

    the value to render

  • depth (Integer) (defaults to: 0)

    current indentation depth

Returns:

  • (String)

    a PowerShell literal



528
529
530
531
532
533
534
535
536
537
538
539
# File 'lib/kitchen/provisioner/dsc.rb', line 528

def ps_hash(obj, depth = 0)
  if obj.is_a?(Hash)
    obj.map do |k, v|
      %{#{pad(depth + 2)}#{ps_hash(k)} = #{ps_hash(v, depth + 2)}}
    end.join(";\n").insert(0, "@{\n").insert(-1, "\n#{pad(depth)}}")
  elsif obj.is_a?(Array)
    array_string = obj.map { |v| ps_hash(v, depth + 4) }.join(",")
    "#{pad(depth)}@(\n#{array_string}\n)"
  else
    %{"#{obj}"}
  end
end

#psmodule_repository_nameString (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

The PowerShellGet repository name to install modules from.

Returns:

  • (String)

    :gallery_name when set, the public PSGallery when neither gallery setting is given, and testing for an unnamed private :gallery_uri



341
342
343
344
345
346
# File 'lib/kitchen/provisioner/dsc.rb', line 341

def psmodule_repository_name
  return "PSGallery" if config[:gallery_name].nil? && config[:gallery_uri].nil?
  return "testing"   if config[:gallery_name].nil?

  config[:gallery_name]
end

#register_psmodule_repositoryString? (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

PowerShell that registers a private gallery as a package source.

Returns:

  • (String, nil)

    the register-packagesource command, or nil when no :gallery_uri is configured



353
354
355
356
357
358
# File 'lib/kitchen/provisioner/dsc.rb', line 353

def register_psmodule_repository
  return if config[:gallery_uri].nil?

  info("Registering a new PowerShellGet Repository - #{psmodule_repository_name}")
  "register-packagesource -providername PowerShellGet -name '#{psmodule_repository_name}' -location '#{config[:gallery_uri]}' -force -trusted"
end

#run_commandString

Builds the command that applies the compiled MOF documents.

A DSC resource may require a reboot to finish. Rather than failing, the generated script reboots the node and exits 35, and this method opts the instance into retrying that exit code so the converge resumes once the node is back. Explicit :retry_on_exit_code and :max_retries settings are left untouched.

Returns:

  • (String)

    PowerShell that starts a DSC configuration job per configuration name and reports its errors



226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
# File 'lib/kitchen/provisioner/dsc.rb', line 226

def run_command
  config[:retry_on_exit_code] = [35] if config[:retry_on_exit_code].empty?
  config[:max_retries] = 3 if config[:max_retries] == 1
  scripts = +""
  ensure_array(config[:configuration_name]).each do |configuration|
    info("Running the configuration #{configuration}")
    run_configuration_script = <<-EOH
      $job = start-dscconfiguration -Path c:/configurations/#{configuration} -force
      $job | wait-job
      $verbose_output = $job.childjobs[0].verbose
      $verbose_output
      if ($verbose_output -match 'A reboot is required to progress further. Please reboot the system.') {
        "A reboot is required to continue."
        shutdown /r /t 15
        exit 35
      }
      $dsc_errors = $job.childjobs[0].Error
      if ($dsc_errors -ne $null) {
        $dsc_errors
        exit 1
      }
    EOH
    scripts << run_configuration_script
  end
  debug("Shelling out: #{scripts}")
  wrap_powershell_code(scripts)
end

#sandboxed_configuration_scriptString (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Path of the configuration script relative to the sandbox and to :root_path on the system under test.

Returns:

  • (String)

    the sandboxed script path



501
502
503
# File 'lib/kitchen/provisioner/dsc.rb', line 501

def sandboxed_configuration_script
  File.join("configuration", config[:configuration_script])
end

#setup_config_directory_scriptString (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

PowerShell that creates the remote directory holding the configuration script.

Returns:

  • (String)

    a mkdir invocation



278
279
280
# File 'lib/kitchen/provisioner/dsc.rb', line 278

def setup_config_directory_script
  "mkdir (split-path (join-path #{config[:root_path]} #{sandboxed_configuration_script})) -force | out-null"
end

#wrap_powershell_code(code) ⇒ String (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Wraps generated PowerShell for execution by the transport.

Progress streams are silenced first: WinRM relays them as output, which makes converge logs unreadable and can slow long-running resources.

Parameters:

  • code (String)

    the PowerShell to wrap

Returns:

  • (String)

    the wrapped command



411
412
413
# File 'lib/kitchen/provisioner/dsc.rb', line 411

def wrap_powershell_code(code)
  wrap_shell_code(["$ProgressPreference = 'SilentlyContinue';", code].join("\n"))
end