diff --git a/lib/puppet/provider/package/windows.rb b/lib/puppet/provider/package/windows.rb index b969ab0ef..143d1c1e6 100644 --- a/lib/puppet/provider/package/windows.rb +++ b/lib/puppet/provider/package/windows.rb @@ -1,113 +1,113 @@ require 'puppet/provider/package' require 'puppet/util/windows' require 'puppet/provider/package/windows/package' Puppet::Type.type(:package).provide(:windows, :parent => Puppet::Provider::Package) do desc "Windows package management. This provider supports either MSI or self-extracting executable installers. This provider requires a `source` attribute when installing the package. It accepts paths to local files, mapped drives, or UNC paths. This provider supports the `install_options` and `uninstall_options` attributes, which allow command-line flags to be passed to the installer. These options should be specified as a string (e.g. '--flag'), a hash (e.g. {'--flag' => 'value'}), or an array where each element is either a string or a hash. If the executable requires special arguments to perform a silent install or uninstall, then the appropriate arguments should be specified using the `install_options` or `uninstall_options` attributes, respectively. Puppet will automatically quote any option that contains spaces." confine :operatingsystem => :windows defaultfor :operatingsystem => :windows has_feature :installable has_feature :uninstallable has_feature :install_options has_feature :uninstall_options has_feature :versionable attr_accessor :package # Return an array of provider instances def self.instances Puppet::Provider::Package::Windows::Package.map do |pkg| provider = new(to_hash(pkg)) provider.package = pkg provider end end def self.to_hash(pkg) { :name => pkg.name, :ensure => pkg.version || :installed, :provider => :windows } end # Query for the provider hash for the current resource. The provider we # are querying, may not have existed during prefetch def query Puppet::Provider::Package::Windows::Package.find do |pkg| if pkg.match?(resource) return self.class.to_hash(pkg) end end nil end def install installer = Puppet::Provider::Package::Windows::Package.installer_class(resource) command = [installer.install_command(resource), install_options].flatten.compact.join(' ') output = execute(command, :failonfail => false, :combine => true) check_result(output.exitstatus) end def uninstall command = [package.uninstall_command, uninstall_options].flatten.compact.join(' ') output = execute(command, :failonfail => false, :combine => true) check_result(output.exitstatus) end # http://msdn.microsoft.com/en-us/library/windows/desktop/aa368542(v=vs.85).aspx self::ERROR_SUCCESS = 0 self::ERROR_SUCCESS_REBOOT_INITIATED = 1641 self::ERROR_SUCCESS_REBOOT_REQUIRED = 3010 # (Un)install may "fail" because the package requested a reboot, the system requested a # reboot, or something else entirely. Reboot requests mean the package was installed # successfully, but we warn since we don't have a good reboot strategy. def check_result(hr) operation = resource[:ensure] == :absent ? 'uninstall' : 'install' case hr when self.class::ERROR_SUCCESS # yeah when self.class::ERROR_SUCCESS_REBOOT_INITIATED warning("The package #{operation}ed successfully and the system is rebooting now.") when self.class::ERROR_SUCCESS_REBOOT_REQUIRED warning("The package #{operation}ed successfully, but the system must be rebooted.") else raise Puppet::Util::Windows::Error.new("Failed to #{operation}", hr) end end - # This only get's called if there is a value to validate, but not if it's absent + # This only gets called if there is a value to validate, but not if it's absent def validate_source(value) fail("The source parameter cannot be empty when using the Windows provider.") if value.empty? end def install_options join_options(resource[:install_options]) end def uninstall_options join_options(resource[:uninstall_options]) end end diff --git a/lib/puppet/provider/package/windows/exe_package.rb b/lib/puppet/provider/package/windows/exe_package.rb index 5525ca0c6..9c9e46fbc 100644 --- a/lib/puppet/provider/package/windows/exe_package.rb +++ b/lib/puppet/provider/package/windows/exe_package.rb @@ -1,70 +1,70 @@ require 'puppet/provider/package/windows/package' class Puppet::Provider::Package::Windows class ExePackage < Puppet::Provider::Package::Windows::Package attr_reader :uninstall_string # Return an instance of the package from the registry, or nil def self.from_registry(name, values) if valid?(name, values) ExePackage.new( values['DisplayName'], values['DisplayVersion'], values['UninstallString'] ) end end # Is this a valid executable package we should manage? def self.valid?(name, values) # See http://community.spiceworks.com/how_to/show/2238 !!(values['DisplayName'] and values['DisplayName'].length > 0 and values['UninstallString'] and values['UninstallString'].length > 0 and values['SystemComponent'] != 1 and # DWORD values['WindowsInstaller'] != 1 and # DWORD name !~ /^KB[0-9]{6}/ and values['ParentKeyName'] == nil and values['Security Update'] == nil and values['Update Rollup'] == nil and values['Hotfix'] == nil) end def initialize(name, version, uninstall_string) super(name, version) @uninstall_string = uninstall_string end # Does this package match the resource? def match?(resource) resource[:name] == name end def self.install_command(resource) - ['cmd.exe', '/c', 'start', '"puppet-install"', '/w', quote(resource[:source])] + ['cmd.exe', '/c', 'start', '"puppet-install"', '/w', munge(resource[:source])] end def uninstall_command # 1. Launch using cmd /c start because if the executable is a console # application Windows will automatically display its console window # 2. Specify a quoted title, otherwise if uninstall_string is quoted, # start will interpret that to be the title, and get confused # 3. Specify /w (wait) to wait for uninstall to finish command = ['cmd.exe', '/c', 'start', '"puppet-uninstall"', '/w'] # Only quote bare uninstall strings, e.g. # C:\Program Files (x86)\Notepad++\uninstall.exe # Don't quote uninstall strings that are already quoted, e.g. # "c:\ruby187\unins000.exe" # Don't quote uninstall strings that contain arguments: # "C:\Program Files (x86)\Git\unins000.exe" /SILENT if uninstall_string =~ /\A[^"]*.exe\Z/i command << "\"#{uninstall_string}\"" else command << uninstall_string end command end end end diff --git a/lib/puppet/provider/package/windows/msi_package.rb b/lib/puppet/provider/package/windows/msi_package.rb index 9245d847d..1b8b04dfb 100644 --- a/lib/puppet/provider/package/windows/msi_package.rb +++ b/lib/puppet/provider/package/windows/msi_package.rb @@ -1,62 +1,62 @@ require 'puppet/provider/package/windows/package' class Puppet::Provider::Package::Windows class MsiPackage < Puppet::Provider::Package::Windows::Package attr_reader :productcode, :packagecode # From msi.h INSTALLSTATE_DEFAULT = 5 # product is installed for the current user INSTALLUILEVEL_NONE = 2 # completely silent installation # Get the COM installer object, it's in a separate method for testing def self.installer # REMIND: when does the COM release happen? WIN32OLE.new("WindowsInstaller.Installer") end # Return an instance of the package from the registry, or nil def self.from_registry(name, values) if valid?(name, values) inst = installer if inst.ProductState(name) == INSTALLSTATE_DEFAULT MsiPackage.new(values['DisplayName'], values['DisplayVersion'], name, # productcode inst.ProductInfo(name, 'PackageCode')) end end end # Is this a valid MSI package we should manage? def self.valid?(name, values) # See http://community.spiceworks.com/how_to/show/2238 !!(values['DisplayName'] and values['DisplayName'].length > 0 and values['SystemComponent'] != 1 and # DWORD values['WindowsInstaller'] == 1 and # DWORD name =~ /\A\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}\Z/i) end def initialize(name, version, productcode, packagecode) super(name, version) @productcode = productcode @packagecode = packagecode end # Does this package match the resource? def match?(resource) resource[:name].casecmp(packagecode) == 0 || resource[:name].casecmp(productcode) == 0 || resource[:name] == name end def self.install_command(resource) - ['msiexec.exe', '/qn', '/norestart', '/i', quote(resource[:source])] + ['msiexec.exe', '/qn', '/norestart', '/i', munge(resource[:source])] end def uninstall_command ['msiexec.exe', '/qn', '/norestart', '/x', productcode] end end end diff --git a/lib/puppet/provider/package/windows/package.rb b/lib/puppet/provider/package/windows/package.rb index 70245c75f..a1526a886 100644 --- a/lib/puppet/provider/package/windows/package.rb +++ b/lib/puppet/provider/package/windows/package.rb @@ -1,80 +1,92 @@ require 'puppet/provider/package' require 'puppet/util/windows' class Puppet::Provider::Package::Windows class Package extend Enumerable extend Puppet::Util::Errors include Puppet::Util::Windows::Registry extend Puppet::Util::Windows::Registry attr_reader :name, :version # Enumerate each package. The appropriate package subclass # will be yielded. def self.each(&block) with_key do |key, values| name = key.name.match(/^.+\\([^\\]+)$/).captures[0] [MsiPackage, ExePackage].find do |klass| if pkg = klass.from_registry(name, values) yield pkg end end end end # Yield each registry key and its values associated with an # installed package. This searches both per-machine and current # user contexts, as well as packages associated with 64 and # 32-bit installers. def self.with_key(&block) %w[HKEY_LOCAL_MACHINE HKEY_CURRENT_USER].each do |hive| [KEY64, KEY32].each do |mode| mode |= KEY_READ begin open(hive, 'Software\Microsoft\Windows\CurrentVersion\Uninstall', mode) do |uninstall| uninstall.each_key do |name, wtime| open(hive, "#{uninstall.keyname}\\#{name}", mode) do |key| yield key, values(key) end end end rescue Puppet::Util::Windows::Error => e raise e unless e.code == Puppet::Util::Windows::Error::ERROR_FILE_NOT_FOUND end end end end # Get the class that knows how to install this resource def self.installer_class(resource) fail("The source parameter is required when using the Windows provider.") unless resource[:source] case resource[:source] when /\.msi"?\Z/i # REMIND: can we install from URL? # REMIND: what about msp, etc MsiPackage when /\.exe"?\Z/i fail("The source does not exist: '#{resource[:source]}'") unless Puppet::FileSystem.exist?(resource[:source]) ExePackage else fail("Don't know how to install '#{resource[:source]}'") end end + def self.munge(value) + quote(replace_forward_slashes(value)) + end + + def self.replace_forward_slashes(value) + if value.include?('/') + value.gsub!('/', "\\") + Puppet.debug('Package source parameter contained /s - replaced with \\s') + end + value + end + def self.quote(value) value.include?(' ') ? %Q["#{value.gsub(/"/, '\"')}"] : value end def initialize(name, version) @name = name @version = version end end end require 'puppet/provider/package/windows/msi_package' require 'puppet/provider/package/windows/exe_package' diff --git a/spec/unit/provider/package/windows/package_spec.rb b/spec/unit/provider/package/windows/package_spec.rb index 247547f08..632fa13a6 100755 --- a/spec/unit/provider/package/windows/package_spec.rb +++ b/spec/unit/provider/package/windows/package_spec.rb @@ -1,126 +1,141 @@ #! /usr/bin/env ruby require 'spec_helper' require 'puppet/provider/package/windows/package' describe Puppet::Provider::Package::Windows::Package do subject { described_class } let(:hklm) { 'HKEY_LOCAL_MACHINE' } let(:hkcu) { 'HKEY_CURRENT_USER' } let(:path) { 'Software\Microsoft\Windows\CurrentVersion\Uninstall' } let(:key) { mock('key', :name => "#{hklm}\\#{path}\\Google") } let(:package) { mock('package') } context '::each' do it 'should generate an empty enumeration' do subject.expects(:with_key) subject.to_a.should be_empty end it 'should yield each package it finds' do subject.expects(:with_key).yields(key, {}) Puppet::Provider::Package::Windows::MsiPackage.expects(:from_registry).with('Google', {}).returns(package) yielded = nil subject.each do |pkg| yielded = pkg end yielded.should == package end end context '::with_key', :if => Puppet.features.microsoft_windows? do it 'should search HKLM (64 & 32) and HKCU (64 & 32)' do seq = sequence('reg') subject.expects(:open).with(hklm, path, subject::KEY64 | subject::KEY_READ).in_sequence(seq) subject.expects(:open).with(hklm, path, subject::KEY32 | subject::KEY_READ).in_sequence(seq) subject.expects(:open).with(hkcu, path, subject::KEY64 | subject::KEY_READ).in_sequence(seq) subject.expects(:open).with(hkcu, path, subject::KEY32 | subject::KEY_READ).in_sequence(seq) subject.with_key { |key, values| } end it 'should ignore file not found exceptions' do ex = Puppet::Util::Windows::Error.new('Failed to open registry key', Puppet::Util::Windows::Error::ERROR_FILE_NOT_FOUND) # make sure we don't stop after the first exception subject.expects(:open).times(4).raises(ex) keys = [] subject.with_key { |key, values| keys << key } keys.should be_empty end it 'should raise other types of exceptions' do ex = Puppet::Util::Windows::Error.new('Failed to open registry key', Puppet::Util::Windows::Error::ERROR_ACCESS_DENIED) subject.expects(:open).raises(ex) expect { subject.with_key{ |key, values| } }.to raise_error(Puppet::Util::Windows::Error, /Access is denied/) end end context '::installer_class' do it 'should require the source parameter' do expect { subject.installer_class({}) }.to raise_error(Puppet::Error, /The source parameter is required when using the Windows provider./) end context 'MSI' do let (:klass) { Puppet::Provider::Package::Windows::MsiPackage } it 'should accept source ending in .msi' do subject.installer_class({:source => 'foo.msi'}).should == klass end it 'should accept quoted source ending in .msi' do subject.installer_class({:source => '"foo.msi"'}).should == klass end it 'should accept source case insensitively' do subject.installer_class({:source => '"foo.MSI"'}).should == klass end it 'should reject source containing msi in the name' do expect { subject.installer_class({:source => 'mymsi.txt'}) }.to raise_error(Puppet::Error, /Don't know how to install 'mymsi.txt'/) end end context 'Unknown' do it 'should reject packages it does not know about' do expect { subject.installer_class({:source => 'basram'}) }.to raise_error(Puppet::Error, /Don't know how to install 'basram'/) end end end + context '::munge' do + it 'should shell quote strings with spaces and fix forward slashes' do + subject.munge('c:/windows/the thing').should == '"c:\windows\the thing"' + end + it 'should leave properly formatted paths alone' do + subject.munge('c:\windows\thething').should == 'c:\windows\thething' + end + end + + context '::replace_forward_slashes' do + it 'should replace forward with back slashes' do + subject.replace_forward_slashes('c:/windows/thing/stuff').should == 'c:\windows\thing\stuff' + end + end + context '::quote' do it 'should shell quote strings with spaces' do subject.quote('foo bar').should == '"foo bar"' end it 'should shell quote strings with spaces and quotes' do subject.quote('"foo bar" baz').should == '"\"foo bar\" baz"' end it 'should not shell quote strings without spaces' do subject.quote('"foobar"').should == '"foobar"' end end it 'should implement instance methods' do pkg = subject.new('orca', '5.0') pkg.name.should == 'orca' pkg.version.should == '5.0' end end