diff options
author | Matt Wrock <matt@mattwrock.com> | 2015-12-07 21:17:03 -0800 |
---|---|---|
committer | Matt Wrock <matt@mattwrock.com> | 2015-12-07 21:17:03 -0800 |
commit | 3e704d162e3ef5dff9e929eca7c82b48c4d66305 (patch) | |
tree | 8b2532bd8208edc62b86ca9ef3ddaf5dc443f9ab | |
parent | dc98ac77aafe4676a45eb16a991f982d20130ed2 (diff) | |
download | chef-3e704d162e3ef5dff9e929eca7c82b48c4d66305.tar.gz |
adds support to installer types inno, nsis, wise and installshield top the windows_package resource
-rw-r--r-- | CHANGELOG.md | 1 | ||||
-rw-r--r-- | DOC_CHANGES.md | 39 | ||||
-rw-r--r-- | RELEASE_NOTES.md | 4 | ||||
-rw-r--r-- | lib/chef/exceptions.rb | 1 | ||||
-rw-r--r-- | lib/chef/provider/package/windows.rb | 109 | ||||
-rw-r--r-- | lib/chef/provider/package/windows/exe.rb | 129 | ||||
-rw-r--r-- | lib/chef/provider/package/windows/msi.rb | 50 | ||||
-rw-r--r-- | lib/chef/provider/package/windows/registry_uninstall_entry.rb | 89 | ||||
-rw-r--r-- | lib/chef/win32/api/file.rb | 52 | ||||
-rw-r--r-- | lib/chef/win32/file.rb | 5 | ||||
-rw-r--r-- | lib/chef/win32/file/version_info.rb | 93 | ||||
-rw-r--r-- | spec/functional/resource/windows_package_spec.rb | 177 | ||||
-rw-r--r-- | spec/functional/win32/version_info_spec.rb | 50 | ||||
-rw-r--r-- | spec/unit/provider/package/windows/exe_spec.rb | 251 | ||||
-rw-r--r-- | spec/unit/provider/package/windows/msi_spec.rb | 104 | ||||
-rw-r--r-- | spec/unit/provider/package/windows_spec.rb | 253 |
16 files changed, 1343 insertions, 64 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 899fa979e8..2ccb511aea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ * [**Nolan Davidson**](https://github.com/nsdavidson) [pr#4014](https://github.com/chef/chef/pull/4014) Adding ksh resource +* [pr#4193](https://github.com/chef/chef/pull/4196) support for inno, nsis, wise and installshield installer types in windows_package resource * [pr#4196](https://github.com/chef/chef/pull/4196) multipackage dpkg_package and bonus fixes * [pr#4185](https://github.com/chef/chef/pull/4185) dpkg provider cleanup * [pr#4165](https://github.com/chef/chef/pull/4165) Multipackage internal API improvements diff --git a/DOC_CHANGES.md b/DOC_CHANGES.md index 58de140ec0..363efd444e 100644 --- a/DOC_CHANGES.md +++ b/DOC_CHANGES.md @@ -78,3 +78,42 @@ either reboot immediately (:reboot_now) or queue a reboot (:request_reboot). Th The --identity-file option to `knife bootstrap` has been deprecated in favor of `knife bootstrap --ssh-identity-file` to better align with other ssh related options. + +### `windows_package` resource + +`windows_package` now supports more than just `MSI`. Most common windows installer types are supported including Inno Setup, Nullsoft, Wise and InstallShield. The new allowed `installer_type` values are: `inno`, `nsis`, `wise`, `installshield`, `custom`, and `msi`. + +Also, while being able to download remote installers from a `HTTP` resource is not new, it looks as though the top of the docs page is incorrect stating that only local installers can be used as a source. + +An unspecified source now only defaults to the name if the name is either a URL or a valid file path. + +Example Nullsoft (`nsis`) package resource: +``` +windows_package 'Mercurial 3.6.1 (64-bit)' do + source 'http://mercurial.selenic.com/release/windows/Mercurial-3.6.1-x64.exe' + checksum 'febd29578cb6736163d232708b834a2ddd119aa40abc536b2c313fc5e1b5831d' +end +``` + +Example Custom `windows_package` resource: +``` +windows_package 'Microsoft Visual C++ 2005 Redistributable' do + source 'https://download.microsoft.com/download/6/B/B/6BB661D6-A8AE-4819-B79F-236472F6070C/vcredist_x86.exe' + installer_type :custom + options '/Q' +end +``` +Using a `:custom` package is one way to install a non `.msi` file that embeds an `msi` based installer. + +Packages can now be removed without the need to include the package `source`. The relevent uninstall metadata will now be discovered from the registry. +``` +windows_package 'Mercurial 3.6.1 (64-bit)' do + action :remove +end +``` +It is important that the package name used when not including the `source` is EXACTLY the same as the display name found in "Add/Remove programs" or the `DisplayName` property in the appropriate registry key: +* HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\Uninstall +* HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Uninstall +* HKEY_LOCAL_MACHINE\Software\Wow6464Node\Microsoft\Windows\CurrentVersion\Uninstall + +Note that if there are multiple versions of a package installed with the same display name, all packages will be removed unless a version is provided in the `version` attribute or can be discovered in the `source` installer file. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index ff7ab9c54c..5a06af0dbb 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -57,6 +57,10 @@ Please see the following for more details : https://docs.chef.io/release/12-6/re This is the first release where we are rolling out a MSI package for Windows that significantly improves the installation time. In a nutshell, the new approach is to deploy and extract a zipped package rather than individually tracking every file as a MSI component. Please note that the first upgrade (ie, an older version of Chef client is already on the machine) may not exhibit the full extent of the speed-up (as MSI is still tracking the older files). New installs, as well as future upgrades, will be sped up. Uninstalls will remove the folder that Chef client is installed to (typically, C:\Opscode\Chef). +## `windows_package` now supports non-`MSI` based Windows installers + +Today you can install `MSI`s using the `windows_package` resource. However, you have had to use the windows cookbook in order to install non `MSI` based installer packages such as Nullsoft, Inno Setup, Installshield and other `EXE` based installers. We have moved and slightly improved the windows cookbook resource into the core chef client. This means you can now run most windows installer types without taking on external cookbook dependencies. + ## Better handling of log_location with chef client service (Windows) This change is for the scenario when running chef client as a Windows service. Currently, a default log_location gets used by the chef client windows service. This log_location overrides any log_location set in the client.rb. In 12.6.0, the behavior is changed to allow the Chef client running as a Windows service to prefer the log_location in client.rb instead. Now, the windows_service_manager will not explicitly pass in a log_location, and therefore the Chef service will always use what is in the client.rb or the typical default path if none is configured. This enables scenarios such as logging to the Windows event log when running chef client as a Windows service. diff --git a/lib/chef/exceptions.rb b/lib/chef/exceptions.rb index 8172311dd6..0f4e74ad11 100644 --- a/lib/chef/exceptions.rb +++ b/lib/chef/exceptions.rb @@ -169,6 +169,7 @@ class Chef class LCMParser < RuntimeError; end class CannotDetermineHomebrewOwner < Package; end + class CannotDetermineWindowsInstallerType < Package; end # Can not create staging file during file deployment class FileContentStagingError < RuntimeError diff --git a/lib/chef/provider/package/windows.rb b/lib/chef/provider/package/windows.rb index 7ff0b71807..ad2a855f2e 100644 --- a/lib/chef/provider/package/windows.rb +++ b/lib/chef/provider/package/windows.rb @@ -32,11 +32,7 @@ class Chef provides :package, os: "windows" provides :windows_package, os: "windows" - # Depending on the installer, we may need to examine installer_type or - # source attributes, or search for text strings in the installer file - # binary to determine the installer type for the user. Since the file - # must be on disk to do so, we have to make this choice in the provider. - require 'chef/provider/package/windows/msi.rb' + require 'chef/provider/package/windows/registry_uninstall_entry.rb' # load_current_resource is run in Chef::Provider#run_action when not in whyrun_mode? def load_current_resource @@ -56,24 +52,64 @@ class Chef @package_provider ||= begin case installer_type when :msi - Chef::Provider::Package::Windows::MSI.new(resource_for_provider) + Chef::Log.debug("#{@new_resource} is MSI") + require 'chef/provider/package/windows/msi' + Chef::Provider::Package::Windows::MSI.new(resource_for_provider, uninstall_registry_entries) else - raise "Unable to find a Chef::Provider::Package::Windows provider for installer_type '#{installer_type}'" + Chef::Log.debug("#{@new_resource} is EXE with type '#{installer_type}'") + require 'chef/provider/package/windows/exe' + Chef::Provider::Package::Windows::Exe.new(resource_for_provider, installer_type, uninstall_registry_entries) end end end def installer_type + # Depending on the installer, we may need to examine installer_type or + # source attributes, or search for text strings in the installer file + # binary to determine the installer type for the user. Since the file + # must be on disk to do so, we have to make this choice in the provider. @installer_type ||= begin if @new_resource.installer_type @new_resource.installer_type - else - file_extension = ::File.basename(@new_resource.source).split(".").last.downcase + elsif source_location.nil? + inferred_registry_type + else + basename = ::File.basename(source_location) + file_extension = basename.split(".").last.downcase if file_extension == "msi" :msi else - raise ArgumentError, "Installer type for Windows Package '#{@new_resource.name}' not specified and cannot be determined from file extension '#{file_extension}'" + # search the binary file for installer type + ::Kernel.open(::File.expand_path(source_location), 'rb') do |io| + filesize = io.size + bufsize = 4096 # read 4K buffers + overlap = 16 # bytes to overlap between buffer reads + + until io.eof + contents = io.read(bufsize) + + case contents + when /inno/i # Inno Setup + return :inno + when /wise/i # Wise InstallMaster + return :wise + when /nullsoft/i # Nullsoft Scriptable Install System + return :nsis + end + + if (io.tell() < filesize) + io.seek(io.tell() - overlap) + end + end + end + + # if file is named 'setup.exe' assume installshield + if basename == 'setup.exe' + :installshield + else + fail Chef::Exceptions::CannotDetermineWindowsInstallerType, "Installer type for Windows Package '#{@new_resource.name}' not specified and cannot be determined from file extension '#{file_extension}'" + end end end end @@ -93,11 +129,11 @@ class Chef # Chef::Provider::Package action_install + action_remove call install_package + remove_package # Pass those calls to the correct sub-provider def install_package(name, version) - package_provider.install_package(name, version) + package_provider.install_package end def remove_package(name, version) - package_provider.remove_package(name, version) + package_provider.remove_package end # @return [Array] new_version(s) as an array @@ -106,15 +142,59 @@ class Chef [new_resource.version] end + # @return [String] candidate_version + def candidate_version + @candidate_version ||= (@new_resource.version || 'latest') + end + + # @return [Array] current_version(s) as an array + # this package provider does not support package arrays + # However, There may be multiple versions for a single + # package so the first element may be a nested array + def current_version_array + [ current_resource.version ] + end + + # @param current_version<String> one or more versions currently installed + # @param new_version<String> version of the new resource + # + # @return [Boolean] true if new_version is equal to or included in current_version + def target_version_already_installed?(current_version, new_version) + Chef::Log.debug("Checking if #{@new_resource} version '#{new_version}' is already installed. #{current_version} is currently installed") + if current_version.is_a?(Array) + current_version.include?(new_version) + else + new_version == current_version + end + end + + def have_any_matching_version? + target_version_already_installed?(current_resource.version, new_resource.version) + end + private + def uninstall_registry_entries + @uninstall_registry_entries ||= Chef::Provider::Package::Windows::RegistryUninstallEntry.find_entries(new_resource.name) + end + + def inferred_registry_type + uninstall_registry_entries.each do |entry| + return :inno if entry.key.end_with?("_is1") + return :msi if entry.uninstall_string.downcase.start_with?("msiexec.exe ") + return :nsis if entry.uninstall_string.downcase.end_with?("uninst.exe\"") + end + nil + end + def downloadable_file_missing? uri_scheme?(new_resource.source) && !::File.exists?(source_location) end def resource_for_provider @resource_for_provider = Chef::Resource::WindowsPackage.new(new_resource.name).tap do |r| - r.source(Chef::Util::PathHelper.validate_path(source_location)) + r.source(Chef::Util::PathHelper.validate_path(source_location)) unless source_location.nil? + r.version(new_resource.version) r.timeout(new_resource.timeout) r.returns(new_resource.returns) r.options(new_resource.options) @@ -151,7 +231,8 @@ class Chef if uri_scheme?(new_resource.source) source_resource.path else - Chef::Util::PathHelper.cleanpath(new_resource.source) + new_source = Chef::Util::PathHelper.cleanpath(new_resource.source) + ::File.exist?(new_source) ? new_source : nil end end diff --git a/lib/chef/provider/package/windows/exe.rb b/lib/chef/provider/package/windows/exe.rb new file mode 100644 index 0000000000..4495868010 --- /dev/null +++ b/lib/chef/provider/package/windows/exe.rb @@ -0,0 +1,129 @@ +# +# Author:: Seth Chisamore (<schisamo@chef.io>) +# Author:: Matt Wrock <matt@mattwrock.com> +# Copyright:: Copyright (c) 2011, 2015 Chef Software, Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# 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 'chef/mixin/shell_out' + +class Chef + class Provider + class Package + class Windows + class Exe + include Chef::Mixin::ShellOut + + def initialize(resource, installer_type, uninstall_entries) + @new_resource = resource + @installer_type = installer_type + @uninstall_entries = uninstall_entries + end + + attr_reader :new_resource + attr_reader :installer_type + attr_reader :uninstall_entries + + # From Chef::Provider::Package + def expand_options(options) + options ? " #{options}" : "" + end + + # Returns a version if the package is installed or nil if it is not. + def installed_version + Chef::Log.debug("#{new_resource} checking package version") + current_installed_version + end + + def package_version + new_resource.version || install_file_version + end + + def install_package + Chef::Log.debug("#{new_resource} installing #{new_resource.installer_type} package '#{new_resource.source}'") + shell_out!( + [ + "start", + "\"\"", + "/wait", + "\"#{new_resource.source}\"", + unattended_flags, + expand_options(new_resource.options), + "& exit %%%%ERRORLEVEL%%%%" + ].join(" "), timeout: new_resource.timeout, returns: new_resource.returns + ) + end + + def remove_package + uninstall_version = new_resource.version || current_installed_version + uninstall_entries.select { |entry| [uninstall_version].flatten.include?(entry.display_version) } + .map { |version| version.uninstall_string }.uniq.each do |uninstall_string| + Chef::Log.debug("Registry provided uninstall string for #{new_resource} is '#{uninstall_string}'") + shell_out!(uninstall_command(uninstall_string), { returns: new_resource.returns }) + end + end + + private + + def uninstall_command(uninstall_string) + uninstall_string.delete!('"') + uninstall_string = [ + %q{/d"}, + ::File.dirname(uninstall_string), + %q{" }, + ::File.basename(uninstall_string), + expand_options(new_resource.options), + " ", + unattended_flags + ].join + %Q{start "" /wait #{uninstall_string} & exit %%%%ERRORLEVEL%%%%} + end + + def current_installed_version + @current_installed_version ||= uninstall_entries.count == 0 ? nil : begin + uninstall_entries.map { |entry| entry.display_version }.uniq + end + end + + def install_file_version + @install_file_version ||= begin + if ::File.exist?(@new_resource.source) + version_info = Chef::ReservedNames::Win32::File.version_info(new_resource.source) + file_version = version_info.FileVersion || version_info.ProductVersion + file_version == '' ? nil : file_version + else + nil + end + end + end + + # http://unattended.sourceforge.net/installers.php + def unattended_flags + case installer_type + when :installshield + '/s /sms' + when :nsis + '/S /NCRC' + when :inno + '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART' + when :wise + '/s' + end + end + end + end + end + end +end diff --git a/lib/chef/provider/package/windows/msi.rb b/lib/chef/provider/package/windows/msi.rb index 7fdbbcff35..1cc636b92e 100644 --- a/lib/chef/provider/package/windows/msi.rb +++ b/lib/chef/provider/package/windows/msi.rb @@ -29,10 +29,14 @@ class Chef include Chef::ReservedNames::Win32::API::Installer if (RUBY_PLATFORM =~ /mswin|mingw32|windows/) && Chef::Platform.supports_msi? include Chef::Mixin::ShellOut - def initialize(resource) + def initialize(resource, uninstall_entries) @new_resource = resource + @uninstall_entries = uninstall_entries end + attr_reader :new_resource + attr_reader :uninstall_entries + # From Chef::Provider::Package def expand_options(options) options ? " #{options}" : "" @@ -40,27 +44,47 @@ class Chef # Returns a version if the package is installed or nil if it is not. def installed_version - Chef::Log.debug("#{@new_resource} getting product code for package at #{@new_resource.source}") - product_code = get_product_property(@new_resource.source, "ProductCode") - Chef::Log.debug("#{@new_resource} checking package status and version for #{product_code}") - get_installed_version(product_code) + if ::File.exist?(new_resource.source) + Chef::Log.debug("#{new_resource} getting product code for package at #{new_resource.source}") + product_code = get_product_property(new_resource.source, "ProductCode") + Chef::Log.debug("#{new_resource} checking package status and version for #{product_code}") + get_installed_version(product_code) + else + uninstall_entries.count == 0 ? nil : begin + uninstall_entries.map { |entry| entry.display_version }.uniq + end + end end def package_version - Chef::Log.debug("#{@new_resource} getting product version for package at #{@new_resource.source}") - get_product_property(@new_resource.source, "ProductVersion") + return new_resource.version if new_resource.version + if ::File.exist?(new_resource.source) + Chef::Log.debug("#{new_resource} getting product version for package at #{new_resource.source}") + get_product_property(new_resource.source, "ProductVersion") + end end - def install_package(name, version) + def install_package # We could use MsiConfigureProduct here, but we'll start off with msiexec - Chef::Log.debug("#{@new_resource} installing MSI package '#{@new_resource.source}'") - shell_out!("msiexec /qn /i \"#{@new_resource.source}\" #{expand_options(@new_resource.options)}", {:timeout => @new_resource.timeout, :returns => @new_resource.returns}) + Chef::Log.debug("#{new_resource} installing MSI package '#{new_resource.source}'") + shell_out!("msiexec /qn /i \"#{new_resource.source}\" #{expand_options(new_resource.options)}", {:timeout => new_resource.timeout, :returns => new_resource.returns}) end - def remove_package(name, version) + def remove_package # We could use MsiConfigureProduct here, but we'll start off with msiexec - Chef::Log.debug("#{@new_resource} removing MSI package '#{@new_resource.source}'") - shell_out!("msiexec /qn /x \"#{@new_resource.source}\" #{expand_options(@new_resource.options)}", {:timeout => @new_resource.timeout, :returns => @new_resource.returns}) + if ::File.exist?(new_resource.source) + Chef::Log.debug("#{new_resource} removing MSI package '#{new_resource.source}'") + shell_out!("msiexec /qn /x \"#{new_resource.source}\" #{expand_options(new_resource.options)}", {:timeout => new_resource.timeout, :returns => new_resource.returns}) + else + uninstall_version = new_resource.version || installed_version + uninstall_entries.select { |entry| [uninstall_version].flatten.include?(entry.display_version) } + .map { |version| version.uninstall_string }.uniq.each do |uninstall_string| + Chef::Log.debug("#{new_resource} removing MSI package version using '#{uninstall_string}'") + uninstall_string += expand_options(new_resource.options) + uninstall_string += " /Q" unless uninstall_string =~ / \/Q\b/ + shell_out!(uninstall_string, {:timeout => new_resource.timeout, :returns => new_resource.returns}) + end + end end end end diff --git a/lib/chef/provider/package/windows/registry_uninstall_entry.rb b/lib/chef/provider/package/windows/registry_uninstall_entry.rb new file mode 100644 index 0000000000..98398adf5e --- /dev/null +++ b/lib/chef/provider/package/windows/registry_uninstall_entry.rb @@ -0,0 +1,89 @@ +# +# Author:: Seth Chisamore (<schisamo@chef.io>) +# Author:: Matt Wrock <matt@mattwrock.com> +# Copyright:: Copyright (c) 2011, 2015 Chef Software, Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# 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 'win32/registry' if (RUBY_PLATFORM =~ /mswin|mingw32|windows/) + +class Chef + class Provider + class Package + class Windows + class RegistryUninstallEntry + + def self.find_entries(package_name) + Chef::Log.debug("Finding uninstall entries for #{package_name}") + entries = [] + [ + [::Win32::Registry::HKEY_LOCAL_MACHINE, (::Win32::Registry::Constants::KEY_READ | 0x0100)], + [::Win32::Registry::HKEY_LOCAL_MACHINE, (::Win32::Registry::Constants::KEY_READ | 0x0200)], + [::Win32::Registry::HKEY_CURRENT_USER] + ].each do |hkey| + desired = hkey.length > 1 ? hkey[1] : ::Win32::Registry::Constants::KEY_READ + begin + ::Win32::Registry.open(hkey[0], UNINSTALL_SUBKEY, desired) do |reg| + reg.each_key do |key, _wtime| + begin + entry = reg.open(key, desired) + display_name = read_registry_property(entry, 'DisplayName') + if display_name == package_name + entries.push(RegistryUninstallEntry.new(hkey, key, entry)) + end + rescue ::Win32::Registry::Error => ex + Chef::Log.debug("Registry error opening key '#{key}' on node #{desired}: #{ex}") + end + end + end + rescue ::Win32::Registry::Error + Chef::Log.debug("Registry error opening hive '#{hkey[0]}' :: #{desired}: #{ex}") + end + end + entries + end + + def self.read_registry_property(data, property) + data[property] + rescue ::Win32::Registry::Error => ex + Chef::Log.debug("Failure to read property '#{property}'") + nil + end + + def initialize(hive, key, registry_data) + Chef::Log.debug("Creating uninstall entry for #{hive}::#{key}") + @hive = hive + @key = key + @data = registry_data + @display_name = RegistryUninstallEntry.read_registry_property(registry_data, 'DisplayName') + @display_version = RegistryUninstallEntry.read_registry_property(registry_data, 'DisplayVersion') + @uninstall_string = RegistryUninstallEntry.read_registry_property(registry_data, 'UninstallString') + end + + attr_reader :hive + attr_reader :key + attr_reader :display_name + attr_reader :display_version + attr_reader :uninstall_string + attr_reader :data + + private + + UNINSTALL_SUBKEY = 'Software\Microsoft\Windows\CurrentVersion\Uninstall'.freeze + end + end + end + end +end diff --git a/lib/chef/win32/api/file.rb b/lib/chef/win32/api/file.rb index 9ff1ad40d6..3618d125a1 100644 --- a/lib/chef/win32/api/file.rb +++ b/lib/chef/win32/api/file.rb @@ -182,7 +182,14 @@ class Chef # Win32 API Bindings ############################################### - ffi_lib 'kernel32' + ffi_lib 'kernel32', 'version' + + # Does not map directly to a win32 struct + # see https://msdn.microsoft.com/en-us/library/windows/desktop/ms647464(v=vs.85).aspx + class Translation < FFI::Struct + layout :w_lang, :WORD, + :w_code_page, :WORD + end =begin typedef struct _FILETIME { @@ -470,6 +477,34 @@ BOOL WINAPI DeviceIoControl( #); safe_attach_function :GetVolumeNameForVolumeMountPointW, [:LPCTSTR, :LPTSTR, :DWORD], :BOOL +=begin +BOOL WINAPI GetFileVersionInfo( + _In_ LPCTSTR lptstrFilename, + _Reserved_ DWORD dwHandle, + _In_ DWORD dwLen, + _Out_ LPVOID lpData +); +=end + safe_attach_function :GetFileVersionInfoW, [:LPCTSTR, :DWORD, :DWORD, :LPVOID], :BOOL + +=begin +DWORD WINAPI GetFileVersionInfoSize( + _In_ LPCTSTR lptstrFilename, + _Out_opt_ LPDWORD lpdwHandle +); +=end + safe_attach_function :GetFileVersionInfoSizeW, [:LPCTSTR, :LPDWORD], :DWORD + +=begin +BOOL WINAPI VerQueryValue( + _In_ LPCVOID pBlock, + _In_ LPCTSTR lpSubBlock, + _Out_ LPVOID *lplpBuffer, + _Out_ PUINT puLen +); +=end + safe_attach_function :VerQueryValueW, [:LPCVOID, :LPCTSTR, :LPVOID, :PUINT], :BOOL + ############################################### # Helpers ############################################### @@ -565,6 +600,21 @@ BOOL WINAPI DeviceIoControl( file_information end + def retrieve_file_version_info(file_name) + file_name = encode_path(file_name) + file_size = GetFileVersionInfoSizeW(file_name, nil) + if file_size == 0 + Chef::ReservedNames::Win32::Error.raise! + end + + version_info = FFI::MemoryPointer.new(file_size) + unless GetFileVersionInfoW(file_name, 0, file_size, version_info) + Chef::ReservedNames::Win32::Error.raise! + end + + version_info + end + end end end diff --git a/lib/chef/win32/file.rb b/lib/chef/win32/file.rb index 700ddb24d3..abfad91fdb 100644 --- a/lib/chef/win32/file.rb +++ b/lib/chef/win32/file.rb @@ -150,6 +150,10 @@ class Chef Info.new(file_name) end + def self.version_info(file_name) + VersionInfo.new(file_name) + end + def self.verify_links_supported! begin CreateSymbolicLinkW(nil) @@ -211,3 +215,4 @@ class Chef end require 'chef/win32/file/info' +require 'chef/win32/file/version_info' diff --git a/lib/chef/win32/file/version_info.rb b/lib/chef/win32/file/version_info.rb new file mode 100644 index 0000000000..2974c8a695 --- /dev/null +++ b/lib/chef/win32/file/version_info.rb @@ -0,0 +1,93 @@ +# +# Author:: Matt Wrock (<matt@mattwrock.com>) +# Copyright:: Copyright 2015 Chef Software, Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# 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 'chef/win32/file' + +class Chef + module ReservedNames::Win32 + class File + + class VersionInfo + + include Chef::ReservedNames::Win32::API::File + + def initialize(file_name) + raise Errno::ENOENT, file_name unless ::File.exist?(file_name) + @file_version_info = retrieve_file_version_info(file_name) + end + + # defining method for each predefined version resource string + # see https://msdn.microsoft.com/en-us/library/windows/desktop/ms647464(v=vs.85).aspx + [ + :Comments, + :CompanyName, + :FileDescription, + :FileVersion, + :InternalName, + :LegalCopyright, + :LegalTrademarks, + :OriginalFilename, + :ProductName, + :ProductVersion, + :PrivateBuild, + :SpecialBuild + ].each do |method| + define_method method do + begin + get_version_info_string(method.to_s) + rescue Chef::Exceptions::Win32APIError + return nil + end + end + end + + private + + def translation + @translation ||= begin + info_ptr = FFI::MemoryPointer.new(:pointer) + unless VerQueryValueW(@file_version_info, "\\VarFileInfo\\Translation".to_wstring, info_ptr, FFI::MemoryPointer.new(:int)) + Chef::ReservedNames::Win32::Error.raise! + end + + # there can potentially be multiple translations but most installers just have one + # we use the first because we use this for the version strings which are language + # agnostic. If/when we need other fields, we should we should add logic to find + # the "best" translation + trans = Translation.new(info_ptr.read_pointer) + to_hex(trans[:w_lang]) + to_hex(trans[:w_code_page]) + end + end + + def to_hex(integer) + integer.to_s(16).rjust(4,"0") + end + + def get_version_info_string(string_key) + info_ptr = FFI::MemoryPointer.new(:pointer) + size_ptr = FFI::MemoryPointer.new(:int) + unless VerQueryValueW(@file_version_info, "\\StringFileInfo\\#{translation}\\#{string_key}".to_wstring, info_ptr, size_ptr) + Chef::ReservedNames::Win32::Error.raise! + end + + info_ptr.read_pointer.read_wstring(size_ptr.read_uint) + end + end + end + end +end diff --git a/spec/functional/resource/windows_package_spec.rb b/spec/functional/resource/windows_package_spec.rb new file mode 100644 index 0000000000..65378653b0 --- /dev/null +++ b/spec/functional/resource/windows_package_spec.rb @@ -0,0 +1,177 @@ +# +# Author:: Matt Wrock (<matt@mattwrock.com>) +# Copyright:: Copyright (c) 2015 Chef Software, Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# 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 'spec_helper' +require 'functional/resource/base' + +describe Chef::Resource::WindowsPackage, :windows_only, :volatile do + let(:pkg_name) { nil } + let(:pkg_path) { nil } + let(:pkg_checksum) { nil } + let(:pkg_version) { nil } + let(:pkg_type) { nil } + let(:pkg_options) { nil } + + subject do + new_resource = Chef::Resource::WindowsPackage.new(pkg_name, run_context) + new_resource.source pkg_path + new_resource.version pkg_version + new_resource.installer_type pkg_type + new_resource.options pkg_options + new_resource.checksum pkg_checksum + new_resource + end + + describe "multi package scenario" do + let(:pkg_name) { 'Microsoft Visual C++ 2005 Redistributable' } + let(:pkg_path) { 'https://download.microsoft.com/download/6/B/B/6BB661D6-A8AE-4819-B79F-236472F6070C/vcredist_x86.exe' } + let(:pkg_checksum) { nil } + let(:pkg_version) { '8.0.59193' } + let(:pkg_type) { :custom } + let(:pkg_options) { "/Q" } + + it "updates resource on first install" do + subject.run_action(:install) + expect(subject).to be_updated_by_last_action + end + + it "does not update resource when already installed" do + subject.run_action(:install) + expect(subject).not_to be_updated_by_last_action + end + + context "installing additional version" do + let(:pkg_path) { 'https://download.microsoft.com/download/e/1/c/e1c773de-73ba-494a-a5ba-f24906ecf088/vcredist_x86.exe' } + let(:pkg_version) { '8.0.56336' } + + it "installs older version" do + subject.run_action(:install) + expect(subject).to be_updated_by_last_action + end + end + + describe "removing package" do + subject { Chef::Resource::WindowsPackage.new(pkg_name, run_context) } + + context "multiple versions and a version given to remove" do + before { subject.version('8.0.56336')} + + it "removes specified version" do + subject.run_action(:remove) + expect(subject).to be_updated_by_last_action + prov = subject.provider_for_action(:remove) + prov.load_current_resource + expect(prov.current_version_array).to eq([['8.0.59193']]) + end + end + + context "single version installed and no version given to remove" do + it "removes last remaining version" do + subject.run_action(:remove) + expect(subject).to be_updated_by_last_action + prov = subject.provider_for_action(:remove) + prov.load_current_resource + expect(prov.current_version_array).to eq([nil]) + end + end + + describe "removing multiple versions at once" do + let(:pkg_version) { nil } + before do + install1 = Chef::Resource::WindowsPackage.new(pkg_name, run_context) + install1.source pkg_path + install1.version pkg_version + install1.installer_type pkg_type + install1.options pkg_options + install1.run_action(:install) + + install2 = Chef::Resource::WindowsPackage.new(pkg_name, run_context) + install2.source 'https://download.microsoft.com/download/e/1/c/e1c773de-73ba-494a-a5ba-f24906ecf088/vcredist_x86.exe' + install2.version '8.0.56336' + install2.installer_type pkg_type + install2.options pkg_options + install2.run_action(:install) + end + + it "removes all versions" do + subject.run_action(:remove) + expect(subject).to be_updated_by_last_action + prov = subject.provider_for_action(:remove) + prov.load_current_resource + expect(prov.current_version_array).to eq([nil]) + end + end + end + end + + describe "package version and installer type" do + after { subject.run_action(:remove) } + + context "null soft" do + let(:pkg_name) { 'Ultra Defragmenter' } + let(:pkg_path) { 'http://iweb.dl.sourceforge.net/project/ultradefrag/stable-release/6.1.1/ultradefrag-6.1.1.bin.amd64.exe' } + let(:pkg_checksum) { '11d53ed4c426c8c867ad43f142b7904226ffd9938c02e37086913620d79e3c09' } + + it "finds the correct package version" do + subject.run_action(:install) + expect(subject.version).to eq('6.1.1') + end + + it "finds the correct installer type" do + subject.run_action(:install) + expect(subject.provider_for_action(:install).installer_type).to eq(:nsis) + end + end + + context "inno" do + let(:pkg_name) { 'Mercurial 3.6.1 (64-bit)' } + let(:pkg_path) { 'http://mercurial.selenic.com/release/windows/Mercurial-3.6.1-x64.exe' } + let(:pkg_checksum) { 'febd29578cb6736163d232708b834a2ddd119aa40abc536b2c313fc5e1b5831d' } + + it "finds the correct package version" do + subject.run_action(:install) + expect(subject.version).to eq(nil) # Mercurial does not include versioning + end + + it "finds the correct installer type" do + subject.run_action(:install) + expect(subject.provider_for_action(:install).installer_type).to eq(:inno) + end + end + end + + describe "install from local file" do + let(:pkg_name) { 'Mercurial 3.6.1 (64-bit)' } + let(:pkg_path) { ::File.join(Chef::Config[:file_cache_path], "package", "Mercurial-3.6.1-x64.exe") } + let(:pkg_checksum) { 'febd29578cb6736163d232708b834a2ddd119aa40abc536b2c313fc5e1b5831d' } + + it "installs the app" do + subject.run_action(:install) + expect(subject).to be_updated_by_last_action + end + end + + describe "uninstall exe without source" do + let(:pkg_name) { 'Mercurial 3.6.1 (64-bit)' } + + it "uninstalls the app" do + subject.run_action(:remove) + expect(subject).to be_updated_by_last_action + end + end +end
\ No newline at end of file diff --git a/spec/functional/win32/version_info_spec.rb b/spec/functional/win32/version_info_spec.rb new file mode 100644 index 0000000000..c7d41f9616 --- /dev/null +++ b/spec/functional/win32/version_info_spec.rb @@ -0,0 +1,50 @@ +# +# Author:: Matt Wrock (<matt@mattwrock.com>) +# Copyright:: Copyright 2015 Chef Software, Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# 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 'spec_helper' +if Chef::Platform.windows? + require 'chef/win32/file/version_info' +end + +describe "Chef::ReservedNames::Win32::File::VersionInfo", :windows_only do + require 'wmi-lite/wmi' + let(:file_path) { ENV['ComSpec'] } + let(:os_version) do + wmi = WmiLite::Wmi.new + os_info = wmi.first_of('Win32_OperatingSystem') + os_info['version'] + end + + subject { Chef::ReservedNames::Win32::File::VersionInfo.new(file_path) } + + it "file version has the same version as windows" do + expect(subject.FileVersion).to start_with(os_version) + end + + it "product version has the same version as windows" do + expect(subject.ProductVersion).to start_with(os_version) + end + + it "company is microsoft" do + expect(subject.CompanyName).to eq("Microsoft Corporation") + end + + it "file description is command processor" do + expect(subject.FileDescription).to eq("Windows Command Processor") + end +end diff --git a/spec/unit/provider/package/windows/exe_spec.rb b/spec/unit/provider/package/windows/exe_spec.rb new file mode 100644 index 0000000000..730df5e067 --- /dev/null +++ b/spec/unit/provider/package/windows/exe_spec.rb @@ -0,0 +1,251 @@ +# +# Author:: Matt Wrock <matt@mattwrock.com> +# Copyright:: Copyright (c) 2015 Chef Software, Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# 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 'spec_helper' +require 'chef/provider/package/windows/exe' + +unless Chef::Platform.windows? + class Chef + module ReservedNames::Win32 + class File + def version_info + nil + end + end + end + end +end + +describe Chef::Provider::Package::Windows::Exe do + let(:package_name) { "calculator" } + let(:resource_source) { "calculator.exe" } + let(:new_resource) do + new_resource = Chef::Resource::WindowsPackage.new(package_name) + new_resource.source(resource_source) + new_resource + end + let(:uninstall_hash) do + [{ + 'DisplayVersion' => 'outdated', + 'UninstallString' => File.join("uninst_dir", "uninst_file") + }] + end + let(:uninstall_entry) do + entries = [] + uninstall_hash.each do |entry| + entries.push(Chef::Provider::Package::Windows::RegistryUninstallEntry.new('hive', 'key', entry)) + end + entries + end + let(:provider) { Chef::Provider::Package::Windows::Exe.new(new_resource, :nsis, uninstall_entry) } + let(:file_version) { nil } + let(:product_version) { nil } + let(:version_info) { instance_double("Chef::ReservedNames::Win32::File::Version_info", FileVersion: file_version, ProductVersion: product_version) } + + before(:each) do + allow(Chef::ReservedNames::Win32::File).to receive(:version_info).and_return(version_info) + allow(::File).to receive(:exist?).with(Chef::Util::PathHelper.canonical_path(resource_source, false)).and_return(true) + end + + it "responds to shell_out!" do + expect(provider).to respond_to(:shell_out!) + end + + describe "expand_options" do + it "returns an empty string if passed no options" do + expect(provider.expand_options(nil)).to eql "" + end + + it "returns a string with a leading space if passed options" do + expect(provider.expand_options("--train nope --town no_way")).to eql(" --train nope --town no_way") + end + end + + describe "installed_version" do + it "returns the installed version" do + expect(provider.installed_version).to eql(["outdated"]) + end + + context "no versions installed" do + let(:uninstall_hash) { [] } + + it "returns the installed version" do + expect(provider.installed_version).to eql(nil) + end + end + end + + describe "package_version" do + before { new_resource.version(nil) } + + context "source file does not exist" do + before do + allow(::File).to receive(:exist?).with(Chef::Util::PathHelper.canonical_path(resource_source, false)).and_return(false) + end + + it "returns nil" do + expect(provider.package_version).to eql(nil) + end + end + + context "file version is empty" do + let(:file_version) { '' } + + it "returns nil" do + expect(provider.package_version).to eql(nil) + end + + it "returns the version of a package if given" do + new_resource.version('v55555') + expect(provider.package_version).to eql('v55555') + end + end + + context "both file and product version are in installer" do + let(:file_version) { '1.1.1' } + let(:product_version) { '1.1' } + + it "returns the file version" do + expect(provider.package_version).to eql('1.1.1') + end + + it "returns the version of a package if given" do + new_resource.version('v55555') + expect(provider.package_version).to eql('v55555') + end + end + + context "only file version is in installer" do + let(:file_version) { '1.1.1' } + + it "returns the file version" do + expect(provider.package_version).to eql('1.1.1') + end + + it "returns the version of a package if given" do + new_resource.version('v55555') + expect(provider.package_version).to eql('v55555') + end + end + + context "only product version is in installer" do + let(:product_version) { '1.1' } + + it "returns the product version" do + expect(provider.package_version).to eql('1.1') + end + + it "returns the version of a package if given" do + new_resource.version('v55555') + expect(provider.package_version).to eql('v55555') + end + end + + context "no version info is in installer" do + let(:file_version) { nil } + let(:product_version) { nil } + + it "returns the version of a package" do + new_resource.version('v55555') + expect(provider.package_version).to eql('v55555') + end + end + + context "no version info is in installer and none in attribute" do + it "returns the version of a package" do + expect(provider.package_version).to eql(nil) + end + end + end + + describe "remove_package" do + context "no version given and one package installed" do + it "removes installed package" do + expect(provider).to receive(:shell_out!).with(/start \"\" \/wait \/d\"uninst_dir\" uninst_file \/S \/NCRC & exit %%%%ERRORLEVEL%%%%/, kind_of(Hash)) + provider.remove_package + end + end + + context "several packages installed" do + let(:uninstall_hash) do + [ + { + 'DisplayVersion' => 'v1', + 'UninstallString' => File.join("uninst_dir1", "uninst_file1") + }, + { + 'DisplayVersion' => 'v2', + 'UninstallString' => File.join("uninst_dir2", "uninst_file2") + } + ] + end + + context "version given and installed" do + it "removes given version" do + new_resource.version('v2') + expect(provider).to receive(:shell_out!).with(/start \"\" \/wait \/d\"uninst_dir2\" uninst_file2 \/S \/NCRC & exit %%%%ERRORLEVEL%%%%/, kind_of(Hash)) + provider.remove_package + end + end + + context "no version given" do + it "removes both versions" do + expect(provider).to receive(:shell_out!).with(/start \"\" \/wait \/d\"uninst_dir1\" uninst_file1 \/S \/NCRC & exit %%%%ERRORLEVEL%%%%/, kind_of(Hash)) + expect(provider).to receive(:shell_out!).with(/start \"\" \/wait \/d\"uninst_dir2\" uninst_file2 \/S \/NCRC & exit %%%%ERRORLEVEL%%%%/, kind_of(Hash)) + provider.remove_package + end + end + end + end + + context "installs nsis installer" do + let(:provider) { Chef::Provider::Package::Windows::Exe.new(new_resource, :nsis, uninstall_entry) } + + it "calls installer with the correct flags" do + expect(provider).to receive(:shell_out!).with(/start \"\" \/wait \"#{Regexp.quote(new_resource.source)}\" \/S \/NCRC & exit %%%%ERRORLEVEL%%%%/, kind_of(Hash)) + provider.install_package + end + end + + context "installs installshield installer" do + let(:provider) { Chef::Provider::Package::Windows::Exe.new(new_resource, :installshield, uninstall_entry) } + + it "calls installer with the correct flags" do + expect(provider).to receive(:shell_out!).with(/start \"\" \/wait \"#{Regexp.quote(new_resource.source)}\" \/s \/sms & exit %%%%ERRORLEVEL%%%%/, kind_of(Hash)) + provider.install_package + end + end + + context "installs inno installer" do + let(:provider) { Chef::Provider::Package::Windows::Exe.new(new_resource, :inno, uninstall_entry) } + + it "calls installer with the correct flags" do + expect(provider).to receive(:shell_out!).with(/start \"\" \/wait \"#{Regexp.quote(new_resource.source)}\" \/VERYSILENT \/SUPPRESSMSGBOXES \/NORESTART & exit %%%%ERRORLEVEL%%%%/, kind_of(Hash)) + provider.install_package + end + end + + context "installs wise installer" do + let(:provider) { Chef::Provider::Package::Windows::Exe.new(new_resource, :wise, uninstall_entry) } + + it "calls installer with the correct flags" do + expect(provider).to receive(:shell_out!).with(/start \"\" \/wait \"#{Regexp.quote(new_resource.source)}\" \/s & exit %%%%ERRORLEVEL%%%%/, kind_of(Hash)) + provider.install_package + end + end +end diff --git a/spec/unit/provider/package/windows/msi_spec.rb b/spec/unit/provider/package/windows/msi_spec.rb index bef202847f..9377dcaad9 100644 --- a/spec/unit/provider/package/windows/msi_spec.rb +++ b/spec/unit/provider/package/windows/msi_spec.rb @@ -17,17 +17,37 @@ # require 'spec_helper' +require 'chef/provider/package/windows/msi' describe Chef::Provider::Package::Windows::MSI do let(:node) { double('Chef::Node') } let(:events) { double('Chef::Events').as_null_object } # mock all the methods let(:run_context) { double('Chef::RunContext', :node => node, :events => events) } - let(:new_resource) { Chef::Resource::WindowsPackage.new("calculator.msi") } - let(:provider) { Chef::Provider::Package::Windows::MSI.new(new_resource) } - - before(:each) do - stub_const("File::ALT_SEPARATOR", "\\") - allow(::File).to receive(:absolute_path).with("calculator.msi").and_return("calculator.msi") + let(:package_name) { "calculator" } + let(:resource_source) { "calculator.msi" } + let(:resource_version) { nil } + let(:new_resource) do + new_resource = Chef::Resource::WindowsPackage.new(package_name) + new_resource.source(resource_source) + new_resource.version(resource_version) + new_resource + end + let(:uninstall_hash) do + [{ + 'DisplayVersion' => 'outdated', + 'UninstallString' => "MsiExec.exe /X{guid}" + }] + end + let(:uninstall_entry) do + entries = [] + uninstall_hash.each do |entry| + entries.push(Chef::Provider::Package::Windows::RegistryUninstallEntry.new('hive', 'key', entry)) + end + entries + end + let(:provider) { Chef::Provider::Package::Windows::MSI.new(new_resource, uninstall_entry) } + before do + allow(::File).to receive(:exist?).with(Chef::Util::PathHelper.canonical_path(resource_source, false)).and_return(true) end it "responds to shell_out!" do @@ -50,6 +70,11 @@ describe Chef::Provider::Package::Windows::MSI do allow(provider).to receive(:get_installed_version).with("{23170F69-40C1-2702-0920-000001000000}").and_return("3.14159.1337.42") expect(provider.installed_version).to eql("3.14159.1337.42") end + + it "returns the installed version in the registry when install file not present" do + allow(::File).to receive(:exist?).with(Chef::Util::PathHelper.canonical_path(resource_source, false)).and_return(false) + expect(provider.installed_version).to eql(["outdated"]) + end end describe "package_version" do @@ -57,19 +82,78 @@ describe Chef::Provider::Package::Windows::MSI do allow(provider).to receive(:get_product_property).with(/calculator.msi$/, "ProductVersion").and_return(42) expect(provider.package_version).to eql(42) end + + context "version is explicitly provided" do + let(:resource_version) { "given_version" } + + it "returns the given version" do + expect(provider.package_version).to eql("given_version") + end + end + + context "no source or version is given" do + before do + allow(::File).to receive(:exist?).with(Chef::Util::PathHelper.canonical_path(resource_source, false)).and_return(false) + end + + it "returns nil" do + expect(provider.package_version).to eql(nil) + end + end end describe "install_package" do it "calls msiexec /qn /i" do - expect(provider).to receive(:shell_out!).with(/msiexec \/qn \/i \"calculator.msi\"/, kind_of(Hash)) - provider.install_package("unused", "unused") + expect(provider).to receive(:shell_out!).with(/msiexec \/qn \/i \"#{Regexp.quote(new_resource.source)}\"/, kind_of(Hash)) + provider.install_package end end describe "remove_package" do it "calls msiexec /qn /x" do - expect(provider).to receive(:shell_out!).with(/msiexec \/qn \/x \"calculator.msi\"/, kind_of(Hash)) - provider.remove_package("unused", "unused") + expect(provider).to receive(:shell_out!).with(/msiexec \/qn \/x \"#{Regexp.quote(new_resource.source)}\"/, kind_of(Hash)) + provider.remove_package + end + + context "no source is provided" do + before do + allow(::File).to receive(:exist?).with(Chef::Util::PathHelper.canonical_path(resource_source, false)).and_return(false) + end + + it "removes installed package" do + expect(provider).to receive(:shell_out!).with(/MsiExec.exe \/X{guid} \/Q/, kind_of(Hash)) + provider.remove_package + end + + context "there are multiple installs" do + let(:uninstall_hash) do + [ + { + 'DisplayVersion' => 'outdated', + 'UninstallString' => "MsiExec.exe /X{guid}" + }, + { + 'DisplayVersion' => 'really_outdated', + 'UninstallString' => "MsiExec.exe /X{guid2}" + } + ] + end + + it "removes both installed package" do + expect(provider).to receive(:shell_out!).with(/MsiExec.exe \/X{guid} \/Q/, kind_of(Hash)) + expect(provider).to receive(:shell_out!).with(/MsiExec.exe \/X{guid2} \/Q/, kind_of(Hash)) + provider.remove_package + end + end + + context "custom options includes /Q" do + before { new_resource.options("/Q") } + + it "does not duplicate quiet switch" do + expect(provider).to receive(:shell_out!).with(/MsiExec.exe \/X{guid} \/Q/, kind_of(Hash)) + provider.remove_package + end + end end end end diff --git a/spec/unit/provider/package/windows_spec.rb b/spec/unit/provider/package/windows_spec.rb index e5acc87694..c26c446b5b 100644 --- a/spec/unit/provider/package/windows_spec.rb +++ b/spec/unit/provider/package/windows_spec.rb @@ -17,6 +17,8 @@ # require 'spec_helper' +require 'chef/provider/package/windows/exe' +require 'chef/provider/package/windows/msi' describe Chef::Provider::Package::Windows, :windows_only do before(:each) do @@ -28,10 +30,19 @@ describe Chef::Provider::Package::Windows, :windows_only do let(:events) { double('Chef::Events').as_null_object } # mock all the methods let(:run_context) { double('Chef::RunContext', :node => node, :events => events) } let(:resource_source) { 'calculator.msi' } - let(:new_resource) { Chef::Resource::WindowsPackage.new(resource_source) } + let(:resource_name) { 'calculator' } + let(:new_resource) do + new_resource = Chef::Resource::WindowsPackage.new(resource_name) + new_resource.source(resource_source) + new_resource + end let(:provider) { Chef::Provider::Package::Windows.new(new_resource, run_context) } let(:cache_path) { 'c:\\cache\\' } + before(:each) do + allow(::File).to receive(:exist?).with(provider.new_resource.source).and_return(true) + end + describe "load_current_resource" do shared_examples "a local file" do before(:each) do @@ -43,7 +54,7 @@ describe Chef::Provider::Package::Windows, :windows_only do it "creates a current resource with the name of the new resource" do provider.load_current_resource expect(provider.current_resource).to be_a(Chef::Resource::WindowsPackage) - expect(provider.current_resource.name).to eql(resource_source) + expect(provider.current_resource.name).to eql(resource_name) end it "sets the current version if the package is installed" do @@ -76,19 +87,6 @@ describe Chef::Provider::Package::Windows, :windows_only do end it_behaves_like "a local file" end - - context "when remote_file_attributes are provided" do - let (:remote_file_attributes) { {:path => 'C:\\foobar.msi'} } - before(:each) do - new_resource.remote_file_attributes(remote_file_attributes) - end - - it 'should override the attributes of the remote file resource used' do - expect(::File).to receive(:exists?).with(remote_file_attributes[:path]) - provider.load_current_resource - end - - end end context "when source is a local file" do @@ -98,6 +96,7 @@ describe Chef::Provider::Package::Windows, :windows_only do describe "package_provider" do shared_examples "a local file" do + it "checks that the source path is valid" do expect(Chef::Util::PathHelper).to receive(:validate_path) provider.package_provider @@ -108,9 +107,29 @@ describe Chef::Provider::Package::Windows, :windows_only do expect(provider.package_provider).to be_a(Chef::Provider::Package::Windows::MSI) end - it "raises an error if the installer_type is unknown" do - allow(provider).to receive(:installer_type).and_return(:apt_for_windows) - expect { provider.package_provider }.to raise_error + it "sets the package provider to Exe if the the installer type is :inno" do + allow(provider).to receive(:installer_type).and_return(:inno) + expect(provider.package_provider).to be_a(Chef::Provider::Package::Windows::Exe) + end + + it "sets the package provider to Exe if the the installer type is :nsis" do + allow(provider).to receive(:installer_type).and_return(:nsis) + expect(provider.package_provider).to be_a(Chef::Provider::Package::Windows::Exe) + end + + it "sets the package provider to Exe if the the installer type is :wise" do + allow(provider).to receive(:installer_type).and_return(:wise) + expect(provider.package_provider).to be_a(Chef::Provider::Package::Windows::Exe) + end + + it "sets the package provider to Exe if the the installer type is :installshield" do + allow(provider).to receive(:installer_type).and_return(:installshield) + expect(provider.package_provider).to be_a(Chef::Provider::Package::Windows::Exe) + end + + it "defaults to exe if the installer_type is unknown" do + allow(provider).to receive(:installer_type).and_return(nil) + expect(provider.package_provider).to be_a(Chef::Provider::Package::Windows::Exe) end end @@ -146,20 +165,202 @@ describe Chef::Provider::Package::Windows, :windows_only do end describe "installer_type" do - it "it returns @installer_type if it is set" do + let(:resource_source) { "microsoft_installer.exe" } + + context "there is no source" do + let(:uninstall_hash) do + [{ + 'DisplayVersion' => 'outdated', + 'UninstallString' => "blah blah" + }] + end + let(:uninstall_key) { "blah" } + let(:uninstall_entry) do + entries = [] + uninstall_hash.each do |entry| + entries.push(Chef::Provider::Package::Windows::RegistryUninstallEntry.new('hive', uninstall_key, entry)) + end + entries + end + + before do + allow(Chef::Provider::Package::Windows::RegistryUninstallEntry).to receive(:find_entries).and_return(uninstall_entry) + allow(::File).to receive(:exist?).with(Chef::Util::PathHelper.canonical_path(resource_source, false)).and_return(false) + end + + context "uninstall string contains MsiExec.exe" do + let(:uninstall_hash) do + [{ + 'DisplayVersion' => 'outdated', + 'UninstallString' => "MsiExec.exe /X{guid}" + }] + end + + it "sets installer_type to MSI" do + expect(provider.installer_type).to eql(:msi) + end + end + + context "uninstall string ends with uninst.exe" do + let(:uninstall_hash) do + [{ + 'DisplayVersion' => 'outdated', + 'UninstallString' => %q{"c:/hfhfheru/uninst.exe"} + }] + end + + it "sets installer_type to NSIS" do + expect(provider.installer_type).to eql(:nsis) + end + end + + context "uninstall key ends in _is1" do + let(:uninstall_key) { "blah_is1" } + + it "sets installer_type to inno" do + expect(provider.installer_type).to eql(:inno) + end + end + + context "eninstall entries is empty" do + before { allow(Chef::Provider::Package::Windows::RegistryUninstallEntry).to receive(:find_entries).and_return([]) } + + it "returns nil" do + expect(provider.installer_type).to eql(nil) + end + end + end + + it "returns @installer_type if it is set" do provider.new_resource.installer_type(:downeaster) expect(provider.installer_type).to eql(:downeaster) end - it "sets installer_type to msi if the source ends in .msi" do - provider.new_resource.source("microsoft_installer.msi") - expect(provider.installer_type).to eql(:msi) + it "sets installer_type to inno if the source contains inno" do + allow(::Kernel).to receive(:open).and_yield(StringIO.new('blah blah inno blah')) + expect(provider.installer_type).to eql(:inno) end - it "raises an error if it cannot determine the installer type" do - provider.new_resource.installer_type(nil) - provider.new_resource.source("tomfoolery.now") - expect { provider.installer_type }.to raise_error(ArgumentError) + it "sets installer_type to wise if the source contains wise" do + allow(::Kernel).to receive(:open).and_yield(StringIO.new('blah blah wise blah')) + expect(provider.installer_type).to eql(:wise) + end + + it "sets installer_type to nsis if the source contains nsis" do + allow(::Kernel).to receive(:open).and_yield(StringIO.new('blah blah nullsoft blah')) + expect(provider.installer_type).to eql(:nsis) + end + + context "source ends in .msi" do + let(:resource_source) { "microsoft_installer.msi" } + + it "sets installer_type to msi" do + expect(provider.installer_type).to eql(:msi) + end + end + + context "the source is setup.exe" do + let(:resource_source) { "setup.exe" } + + it "sets installer_type to installshield" do + allow(::Kernel).to receive(:open).and_yield(StringIO.new('')) + expect(provider.installer_type).to eql(:installshield) + end + end + + context "cannot determine the installer type" do + let(:resource_source) { "tomfoolery.now" } + + it "raises an error" do + allow(::Kernel).to receive(:open).and_yield(StringIO.new('')) + provider.new_resource.installer_type(nil) + expect { provider.installer_type }.to raise_error(Chef::Exceptions::CannotDetermineWindowsInstallerType) + end + end + end + + describe "action_install" do + let(:new_resource) { Chef::Resource::WindowsPackage.new("blah.exe") } + before do + new_resource.installer_type(:inno) + allow_any_instance_of(Chef::Provider::Package::Windows::Exe).to receive(:package_version).and_return(new_resource.version) + end + + context "no version given, discovered or installed" do + it "installs latest" do + expect(provider).to receive(:install_package).with("blah.exe", "latest") + provider.run_action(:install) + end + end + + context "no version given or discovered but package is installed" do + before { allow(provider).to receive(:current_version_array).and_return(["5.5.5"]) } + + it "does not install" do + expect(provider).not_to receive(:install_package) + provider.run_action(:install) + end + end + + context "a version is given and none is installed" do + before { new_resource.version('5.5.5') } + + it "installs given version" do + expect(provider).to receive(:install_package).with("blah.exe", "5.5.5") + provider.run_action(:install) + end + end + + context "a version is given and several are installed" do + context "given version matches an installed version" do + before do + new_resource.version('5.5.5') + allow(provider).to receive(:current_version_array).and_return([ ["5.5.5", "4.3.0", "1.1.1"] ]) + end + + it "does not install" do + expect(provider).not_to receive(:install_package) + provider.run_action(:install) + end + end + + context "given version does not match an installed version" do + before do + new_resource.version('5.5.5') + allow(provider).to receive(:current_version_array).and_return([ ["5.5.0", "4.3.0", "1.1.1"] ]) + end + + it "installs given version" do + expect(provider).to receive(:install_package).with("blah.exe", "5.5.5") + provider.run_action(:install) + end + end + end + + context "a version is given and one is installed" do + context "given version matches installed version" do + before do + new_resource.version('5.5.5') + allow(provider).to receive(:current_version_array).and_return(["5.5.5"]) + end + + it "does not install" do + expect(provider).not_to receive(:install_package) + provider.run_action(:install) + end + end + + context "given version does not match installed version" do + before do + new_resource.version('5.5.5') + allow(provider).to receive(:current_version_array).and_return(["5.5.0"]) + end + + it "installs given version" do + expect(provider).to receive(:install_package).with("blah.exe", "5.5.5") + provider.run_action(:install) + end + end end end end |