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 /lib | |
parent | dc98ac77aafe4676a45eb16a991f982d20130ed2 (diff) | |
download | chef-3e704d162e3ef5dff9e929eca7c82b48c4d66305.tar.gz |
adds support to installer types inno, nsis, wise and installshield top the windows_package resource
Diffstat (limited to 'lib')
-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 |
8 files changed, 500 insertions, 28 deletions
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 |