lib/kitchen/verifier/pester.rb in kitchen-pester-0.12.2 vs lib/kitchen/verifier/pester.rb in kitchen-pester-1.0.0

- old
+ new

@@ -1,7 +1,5 @@ -# -*- encoding: utf-8 -*- -# # Author:: Steven Murawski (<steven.murawski@gmail.com>) # # Copyright (C) 2015, Steven Murawski # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -14,13 +12,16 @@ # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +require "fileutils" require "pathname" +require "kitchen/util" require "kitchen/verifier/base" require "kitchen/version" +require "base64" require_relative "pester_version" module Kitchen module Verifier @@ -30,13 +31,28 @@ kitchen_verifier_api_version 1 plugin_version Kitchen::Verifier::PESTER_VERSION default_config :restart_winrm, false - default_config :test_folder - default_config :use_local_pester_module, false + default_config :test_folder, "tests" + default_config :remove_builtin_powershellget, true + default_config :remove_builtin_pester, true + default_config :skip_pester_install, false + default_config :bootstrap, { + repository_url: "https://www.powershellgallery.com/api/v2", + modules: [], + } + default_config :register_repository, [] + default_config :pester_install, { + SkipPublisherCheck: true, + Force: true, + ErrorAction: "Stop", + } + default_config :install_modules, [] default_config :downloads, ["./PesterTestResults.xml"] => "./testresults" + default_config :copy_folders, [] + default_config :sudo, false # Creates a new Verifier object using the provided configuration data # which will be merged with any default configuration. # # @param config [Hash] provided verifier configuration @@ -62,25 +78,60 @@ # # any further file copies, preparations, etc. # end # end def create_sandbox super - prepare_powershell_modules + prepare_supporting_psmodules + prepare_copy_folders prepare_pester_tests prepare_helpers + + debug("\n\n") + debug("Sandbox content:\n") + list_files(sandbox_path).each do |f| + debug(" #{f}") + end end # Generates a command string which will install and configure the # verifier software on an instance. If no work is required, then `nil` # will be returned. + # PowerShellGet & Pester Bootstrap are done in prepare_command (after sandbox is transferred) + # so that we can use the PesterUtil.psm1 # # @return [String] a command string def install_command - return if local_suite_files.empty? - return if config[:use_local_pester_module] + # the sandbox has not yet been copied to the SUT. + install_command_string = <<-PS1 + Write-Verbose 'Running Install Command...' + $modulesToRemove = @( + if ($#{config[:remove_builtin_powershellget]}) { + Get-module -ListAvailable -FullyQualifiedName @{ModuleName = 'PackageManagement'; RequiredVersion = '1.0.0.1'} + Get-module -ListAvailable -FullyQualifiedName @{ModuleName = 'PowerShellGet'; RequiredVersion = '1.0.0.1'} + } - really_wrap_shell_code(install_command_script) + if ($#{config[:remove_builtin_pester]}) { + Get-module -ListAvailable -FullyQualifiedName @{ModuleName = 'Pester'; RequiredVersion = '3.4.0'} + } + ) + + if ($modulesToRemove.ModuleBase.Count -eq 0) { + # for PS7 on linux + return + } + + $modulesToRemove.ModuleBase | Foreach-Object { + $ModuleBaseLeaf = Split-Path -Path $_ -Leaf + if ($ModuleBaseLeaf -as [System.version]) { + Remove-Item -force -Recurse (Split-Path -Parent -Path $_) -ErrorAction SilentlyContinue + } + else { + Remove-Item -force -Recurse $_ -ErrorAction SilentlyContinue + } + } + PS1 + really_wrap_shell_code(Util.outdent!(install_command_string)) end # Generates a command string which will perform any data initialization # or configuration required after the verifier software is installed # but before the sandbox has been transferred to the instance. If no work @@ -95,48 +146,49 @@ # configuration required just before the main verifier run command but # after the sandbox has been transferred to the instance. If no work is # required, then `nil` will be returned. # # @return [String] a command string - def prepare_command; end + def prepare_command + info("Preparing the SUT and Pester dependencies...") + really_wrap_shell_code(install_command_script) + end # Generates a command string which will invoke the main verifier # command on the prepared instance. If no work is required, then `nil` # will be returned. # # @return [String] a command string def run_command - return if local_suite_files.empty? - really_wrap_shell_code(run_command_script) end # Download functionality was added to the base verifier behavior after # version 2.3.4 if Gem::Version.new(Kitchen::VERSION) <= Gem::Version.new("2.3.4") def call(state) super ensure - download_test_files(state) + download_test_files(state) unless config[:download].nil? end else def call(state) super rescue # If the verifier reports failure, we need to download the files ourselves. # Test Kitchen's base verifier doesn't have the download in an `ensure` block. - download_test_files(state) + download_test_files(state) unless config[:download].nil? # Rethrow original exception, we still want to register the failure. raise end end # private def run_command_script - <<-CMD - Import-Module -Name Pester -Force + <<-PS1 + Import-Module -Name Pester -Force -ErrorAction Stop $TestPath = Join-Path "#{config[:root_path]}" -ChildPath "suites" $OutputFilePath = Join-Path "#{config[:root_path]}" -ChildPath 'PesterTestResults.xml' $options = New-PesterOption -TestSuiteName "Pester - #{instance.to_str}" @@ -146,152 +198,188 @@ $LASTEXITCODE = $result.FailedCount $host.SetShouldExit($LASTEXITCODE) exit $LASTEXITCODE - CMD + PS1 end - def really_wrap_shell_code(code) - wrap_shell_code(Util.outdent!(use_local_powershell_modules(code))) - end + def get_powershell_modules_from_nugetapi + # don't return anything is the modules subkey or bootstrap is null + return if config.dig(:bootstrap, :modules).nil? - def use_local_powershell_modules(script) - <<-EOH - try { - Set-ExecutionPolicy Unrestricted -force - } - catch { - $_ | Out-String | Write-Warning - } + bootstrap = config[:bootstrap] + # if the repository url is set, use that as parameter to Install-ModuleFromNuget. Default is the PSGallery url + gallery_url_param = bootstrap[:repository_url] ? "-GalleryUrl '#{bootstrap[:repository_url]}'" : "" - $global:ProgressPreference = 'SilentlyContinue' - $env:PSModulePath = "$(Join-Path "#{config[:root_path]}" -ChildPath 'modules');$env:PSModulePath" - - #{script} - EOH + info("Bootstrapping environment without PowerShellGet Provider...") + Array(bootstrap[:modules]).map do |powershell_module| + if powershell_module.is_a? Hash + <<-PS1 + ${#{powershell_module[:Name]}} = #{ps_hash(powershell_module)} + Install-ModuleFromNuget -Module ${#{powershell_module[:Name]}} #{gallery_url_param} + PS1 + else + <<-PS1 + Install-ModuleFromNuget -Module @{Name = '#{powershell_module}'} #{gallery_url_param} + PS1 + end + end end - def install_command_script - <<-EOH - [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + # Returns the string command to set a PS Repository + # for each PSRepo configured. + # + # @return [Array<String>] array of suite files + # @api private + def register_psrepository + return if config[:register_repository].nil? - function Confirm-Directory { - [CmdletBinding()] - param($Path) + info("Registering a new PowerShellGet Repository") + Array(config[:register_repository]).map do |psrepo| + # Using Set-PSRepo from ../../*/*/*/PesterUtil.psm1 + debug("Command to set PSRepo #{psrepo[:Name]}.") + <<-PS1 + Write-Host 'Registering psrepo #{psrepo[:Name]}...' + ${#{psrepo[:Name]}} = #{ps_hash(psrepo)} + Set-PSRepo -Repository ${#{psrepo[:Name]}} + PS1 + end + end - $Item = if (Test-Path $Path) { - Get-Item -Path $Path - } - else { - New-Item -Path $Path -ItemType Directory - } + # Returns the string command set the PSGallery as trusted, and + # Install Pester from gallery based on the params from Pester_install_params config + # + # @return <String> command to install Pester Module + # @api private + def install_pester + return if config[:skip_pester_install] - $Item.FullName + pester_install_params = config[:pester_install] || {} + <<-PS1 + if ((Get-PSRepository -Name PSGallery).InstallationPolicy -ne 'Trusted') { + Write-Host -Object "Trusting the PSGallery to install Pester without -Force" + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction SilentlyContinue } - function Test-Module { - [CmdletBinding()] - param($Name) + Write-Host "Installing Pester..." + $installPesterParams = #{ps_hash(pester_install_params)} + $installPesterParams['Name'] = 'Pester' + Install-module @installPesterParams + Write-Host 'Pester Installed.' + PS1 + end - @(Get-Module -Name $Name -ListAvailable -ErrorAction SilentlyContinue).Count -gt 0 - } + # returns a piece of PS scriptblock for each Module to install + # from gallery that has been sepcified in install_modules config. + # + # @return [Array<String>] array of PS commands. + # @api private + def install_modules_from_gallery + return if config[:install_modules].nil? - $VerifierModulePath = Confirm-Directory -Path (Join-Path #{config[:root_path]} -ChildPath 'modules') - $VerifierDownloadPath = Confirm-Directory -Path (Join-Path #{config[:root_path]} -ChildPath 'pester') + Array(config[:install_modules]).map do |powershell_module| + if powershell_module.is_a? Hash + # Sanitize variable name so that $powershell-yaml becomes $powershell_yaml + module_name = powershell_module[:Name].gsub(/[\W]/, "_") + # so we can splat that variable to install module + <<-PS1 + $#{module_name} = #{ps_hash(powershell_module)} + Write-Host -NoNewline 'Installing #{module_name}' + Install-Module @#{module_name} + Write-host '... done.' + PS1 + else + <<-PS1 + Write-host -NoNewline 'Installing #{powershell_module} ...' + Install-Module -Name '#{powershell_module}' + Write-host '... done.' + PS1 + end + end + end - $env:PSModulePath = "$VerifierModulePath;$PSModulePath" + def really_wrap_shell_code(code) + windows_os? ? really_wrap_windows_shell_code(code) : really_wrap_posix_shell_code(code) + end - if (-not (Test-Module -Name Pester)) { - if (Test-Module -Name PowerShellGet) { - Import-Module PowerShellGet -Force - Import-Module PackageManagement -Force + def really_wrap_windows_shell_code(code) + wrap_shell_code(Util.outdent!(use_local_powershell_modules(code))) + end - Get-PackageProvider -Name NuGet -Force > $null + # Writing the command to a ps1 file, adding the pwsh shebang + # invoke the file + def really_wrap_posix_shell_code(code) + if config[:sudo] + pwsh_cmd = "sudo pwsh" + else + pwsh_cmd = "pwsh" + end - Install-Module Pester -Force - } - else { - if (-not (Test-Module -Name PsGet)){ - $webClient = New-Object -TypeName System.Net.WebClient + my_command = <<-BASH + echo "Running as '$(whoami)'" + # Send the bash heredoc 'EOF' to the file current.ps1 using the tool cat + cat << 'EOF' > current.ps1 + #!/usr/bin/env pwsh + #{Util.outdent!(use_local_powershell_modules(code))} + EOF + # create the modules folder, making sure it's done as current user (not root) + mkdir -p foo #{config[:root_path]}/modules + # Invoke the created current.ps1 file using pwsh + #{pwsh_cmd} -f current.ps1 + BASH - if ($env:HTTP_PROXY){ - if ($env:NO_PROXY){ - Write-Host "Creating WebProxy with 'HTTP_PROXY' and 'NO_PROXY' environment variables. - $webproxy = New-Object -TypeName System.Net.WebProxy -ArgumentList $env:HTTP_PROXY, $true, $env:NO_PROXY - } - else { - Write-Host "Creating WebProxy with 'HTTP_PROXY' environment variable. - $webproxy = New-Object -TypeName System.Net.WebProxy -ArgumentList $env:HTTP_PROXY - } + debug(Util.outdent!(my_command)) + Util.outdent!(my_command) + end - $webClient.Proxy = $webproxy - } + def use_local_powershell_modules(script) + <<-PS1 + try { + if (!$IsLinux -and !$IsMacOs) { + Set-ExecutionPolicy Unrestricted -force + } + } + catch { + $_ | Out-String | Write-Warning + } - Invoke-Expression -Command $webClient.DownloadString('http://bit.ly/GetPsGet') - } + $global:ProgressPreference = 'SilentlyContinue' + $PSModPathToPrepend = Join-Path "#{config[:root_path]}" -ChildPath 'modules' + Write-Verbose "Adding '$PSModPathToPrepend' to `$Env:PSModulePath." + if (!$isLinux -and -not (Test-Path -Path $PSModPathToPrepend)) { + # if you create this folder now un Linux, it will run as root (via sudo). + $null = New-Item -Path $PSModPathToPrepend -Force -ItemType Directory + } + + if ($Env:PSModulePath.Split([io.path]::PathSeparator) -notcontains $PSModPathToPrepend) { + $env:PSModulePath = @($PSModPathToPrepend, $env:PSModulePath) -Join [io.path]::PathSeparator + } - try { - # If the module isn't already loaded, ensure we can import it. - if (-not (Get-Module -Name PsGet -ErrorAction SilentlyContinue)) { - Import-Module -Name PsGet -Force -ErrorAction Stop - } + #{script} + PS1 + end - Install-Module -Name Pester -Force - } - catch { - Write-Host "Installing from Github" + def install_command_script + <<-PS1 + $PSModPathToPrepend = "#{config[:root_path]}" - $zipFile = Join-Path (Get-Item -Path $VerifierDownloadPath).FullName -ChildPath "pester.zip" + Import-Module -ErrorAction Stop PesterUtil - if (-not (Test-Path $zipfile)) { - $source = 'https://github.com/pester/Pester/archive/4.10.1.zip' - $webClient = New-Object -TypeName Net.WebClient + #{get_powershell_modules_from_nugetapi.join("\n") unless config.dig(:bootstrap, :modules).nil?} - if ($env:HTTP_PROXY) { - if ($env:NO_PROXY) { - Write-Host "Creating WebProxy with 'HTTP_PROXY' and 'NO_PROXY' environment variables." - $webproxy = New-Object -TypeName System.Net.WebProxy -ArgumentList $env:HTTP_PROXY, $true, $env:NO_PROXY - } - else { - Write-Host "Creating WebProxy with 'HTTP_PROXY' environment variable." - $webproxy = New-Object -TypeName System.Net.WebProxy -ArgumentList $env:HTTP_PROXY - } + #{register_psrepository.join("\n") unless config[:register_repository].nil?} - $webClient.Proxy = $webproxy - } + #{install_pester} - [IO.File]::WriteAllBytes($zipfile, $webClient.DownloadData($source)) - - [GC]::Collect() - Write-Host "Downloaded Pester.zip" - } - - Write-Host "Creating Shell.Application COM object" - $shellcom = New-Object -ComObject Shell.Application - - Write-Host "Creating COM object for zip file." - $zipcomobject = $shellcom.Namespace($zipfile) - - Write-Host "Creating COM object for module destination." - $destination = $shellcom.Namespace($VerifierModulePath) - - Write-Host "Unpacking zip file." - $destination.CopyHere($zipcomobject.Items(), 0x610) - - Rename-Item -Path (Join-Path $VerifierModulePath -ChildPath "Pester-4.10.1") -NewName 'Pester' -Force - } - } - } - - if (-not (Test-Module Pester)) { - throw "Unable to install Pester. Please include Pester in your base image or install during your converge." - } - EOH + #{install_modules_from_gallery.join("\n") unless config[:install_modules].nil?} + PS1 end def restart_winrm_service + return unless verifier.windows_os? + cmd = "schtasks /Create /TN restart_winrm /TR " \ '"powershell -Command Restart-Service winrm" ' \ "/SC ONCE /ST 00:00 " wrap_shell_code(Util.outdent!(<<-CMD #{cmd} @@ -299,12 +387,13 @@ CMD )) end def download_test_files(state) - info("Downloading test result files from #{instance.to_str}") + return if config[:downloads].nil? + info("Downloading test result files from #{instance.to_str}") instance.transport.connection(state) do |conn| config[:downloads].to_h.each do |remotes, local| debug("Downloading #{Array(remotes).join(", ")} to #{local}") conn.download(remotes, local) end @@ -317,35 +406,31 @@ # residing on the local workstation. Any special provisioner-specific # directories (such as a Chef roles/ directory) are excluded. # # @return [Array<String>] array of suite files # @api private - def suite_test_folder @suite_test_folder ||= File.join(test_folder, config[:suite_name]) end - def suite_level_glob - Dir.glob(File.join(suite_test_folder, "*")) + # Returns the current file's parent folder's full path. + # + # @return [string] + # @api private + def script_root + @script_root ||= File.dirname(__FILE__) end - def suite_verifier_level_glob - Dir.glob(File.join(suite_test_folder, "*/**/*")) + # Returns the absolute path of the Supporting PS module to + # be copied to the SUT via the Sandbox. + # + # @return [string] + # @api private + def support_psmodule_folder + @support_psmodule_folder ||= Pathname.new(File.join(script_root, "../../support/modules/PesterUtil")).cleanpath end - def local_suite_files - suite = suite_level_glob - suite_verifier = suite_verifier_level_glob - (suite << suite_verifier).flatten!.reject do |f| - File.directory?(f) - end - end - - def sandboxify_path(path) - File.join(sandbox_path, "suites", path.sub(%r{#{suite_test_folder}/}i, "")) - end - # Returns an Array of common helper filenames currently residing on the # local workstation. # # @return [Array<String>] array of helper files # @api private @@ -367,52 +452,130 @@ FileUtils.mkdir_p(File.dirname(dest)) FileUtils.cp(src, dest, preserve: true) end end - # Copies all test suite files into the suites directory in the sandbox. + # Creates a PowerShell hashtable from a ruby map. + # The only types supported for now are hash, array, string and Boolean. # # @api private - def prepare_pester_tests - info("Preparing to copy files from #{suite_test_folder} to the SUT.") + def ps_hash(obj, depth = 0) + if [true, false].include? obj + %{$#{obj}} # Return $true or $false when value is a bool + elsif obj.is_a?(Hash) + obj.map do |k, v| + # Format "Key = Value" enabling recursion + %{#{pad(depth + 2)}#{ps_hash(k)} = #{ps_hash(v, depth + 2)}} + end + .join("\n") # append \n to the key/value definitions + .insert(0, "@{\n") # prepend @{\n + .insert(-1, "\n#{pad(depth)}}\n") # append \n}\n - local_suite_files.each do |src| - dest = sandboxify_path(src) - debug("Copying #{src} to #{dest}") - FileUtils.mkdir_p(File.dirname(dest)) - FileUtils.cp(src, dest, preserve: true) + elsif obj.is_a?(Array) + array_string = obj.map { |v| ps_hash(v, depth + 4) }.join(",") + "#{pad(depth)}@(\n#{array_string}\n)" + else + # When the object is not a string nor a hash or array, it will be quoted as a string. + # In most cases, PS is smart enough to convert back to the type it needs. + "'" + obj.to_s + "'" end end - def prepare_powershell_module(name) - FileUtils.mkdir_p(File.join(sandbox_path, "modules/#{name}")) - FileUtils.cp( - File.join(File.dirname(__FILE__), "../../support/powershell/#{name}/#{name}.psm1"), - File.join(sandbox_path, "modules/#{name}/#{name}.psm1"), - preserve: true - ) + # returns the path of the modules subfolder + # in the sandbox, where PS Modules and folders will be copied to. + # + # @api private + def sandbox_module_path + File.join(sandbox_path, "modules") end - def prepare_powershell_modules - info("Preparing to copy supporting powershell modules.") - %w{PesterUtil}.each do |module_name| - prepare_powershell_module module_name + # copy files into the 'modules' folder of the sandbox, + # so that copied folders can be discovered with the updated $Env:PSModulePath. + # + # @api private + def prepare_copy_folders + return if config[:copy_folders].nil? + + info("Preparing to copy specified folders to #{sandbox_module_path}.") + kitchen_root_path = config[:kitchen_root] + config[:copy_folders].each do |folder| + debug("copying #{folder}") + folder_to_copy = File.join(kitchen_root_path, folder) + copy_if_src_exists(folder_to_copy, sandbox_module_path) end end - def test_folder - return config[:test_base_path] if config[:test_folder].nil? + # returns an array of string + # Creates a flat list of files contained in a folder. + # This is useful when trying to debug what has been copied to + # the sandbox. + # + # @return [Array<String>] array of files in a folder + # @api private + def list_files(path) + base_directory_content = Dir.glob(File.join(path, "*")) + nested_directory_content = Dir.glob(File.join(path, "*/**/*")) + [base_directory_content, nested_directory_content].flatten + end - absolute_test_folder + # Copies all test suite files into the suites directory in the sandbox. + # + # @api private + def prepare_pester_tests + info("Preparing to copy files from '#{suite_test_folder}' to the SUT.") + sandboxed_suites_path = File.join(sandbox_path, "suites") + copy_if_src_exists(suite_test_folder, sandboxed_suites_path) end + def prepare_supporting_psmodules + debug("Preparing to copy files from '#{support_psmodule_folder}' to the SUT.") + sandbox_module_path = File.join(sandbox_path, "modules") + copy_if_src_exists(support_psmodule_folder, sandbox_module_path) + end + + # Copies a folder recursively preserving its layers, + # mostly used to copy to the sandbox. + # + # @api private + def copy_if_src_exists(src_to_validate, destination) + unless Dir.exist?(src_to_validate) + info("The path #{src_to_validate} was not found. Not copying to #{destination}.") + return + end + + debug("Moving #{src_to_validate} to #{destination}") + unless Dir.exist?(destination) + FileUtils.mkdir_p(destination) + debug("Folder '#{destination}' created.") + end + FileUtils.mkdir_p(File.join(destination, "__bugfix")) + FileUtils.cp_r(src_to_validate, destination, preserve: true) + end + + # returns the absolute path of the folders containing the + # test suites, use default if not set. + # + # @api private + def test_folder + config[:test_folder].nil? ? config[:test_base_path] : absolute_test_folder + end + + # returns the absolute path of the relative folders containing the + # test suites, use default i not set. + # + # @api private def absolute_test_folder path = (Pathname.new config[:test_folder]).realpath integration_path = File.join(path, "integration") - return path unless Dir.exist?(integration_path) - - integration_path + Dir.exist?(integration_path) ? integration_path : path end + # returns a string of space of the specified depth. + # This is used to pad messages or when building PS hashtables. + # + # @api private + def pad(depth = 0) + " " * depth + end end end end