class Chef::Provider::Package::Chocolatey

Constants

CHOCO_MISSING_MSG
PATHFINDING_POWERSHELL_COMMAND

Public Instance Methods

cache_is_valid?() click to toggle source

This checks that the repo list has not changed between now and when we last checked the cache

# File lib/chef/provider/package/chocolatey.rb, line 168
def cache_is_valid?
  return false if @@choco_config.nil? || (actual_config != @@choco_config)

  true
end
candidate_version() click to toggle source

Lazy initializer for candidate_version. A nil value means that there is no candidate version and the package is not installable (generally an error).

@return [Array] list of candidate_versions indexed same as new_resource.package_name/version

# File lib/chef/provider/package/chocolatey.rb, line 83
def candidate_version
  @candidate_version ||= build_candidate_versions
end
check_resource_semantics!() click to toggle source

Override the superclass check. The semantics for our new_resource.source is not files to install from, but like the rubygem provider’s sources which are more like repos.

# File lib/chef/provider/package/chocolatey.rb, line 144
def check_resource_semantics!; end
collect_package_requests(ignore_list: [], walk_resource_tree: false) click to toggle source

Find the set of packages to ask the chocolatey server about

if walk_resource_tree is true, this finds all of the packages that we have referenced anywhere in our recipes - this is so we can attempt to query them all in a single transaction. However, currently we don’t do that - see the comment on available_packages for details of the why, but the TL;DR is that the public chocolatey servers do not support ‘or` type queries properly.

If walk_resource_tree is false, we don’t do any of that - we just filter the package list based on cache data. This is the default due to reasons explained in the comment on available_packages - the goal is to eventually turn this back on, hence the default false parameter here.

@return [Array] List of chocolatey packages referenced in the run list

# File lib/chef/provider/package/chocolatey.rb, line 189
def collect_package_requests(ignore_list: [], walk_resource_tree: false)
  return ["*"] if new_resource.bulk_query || Chef::Config[:always_use_bulk_chocolatey_package_list]

  if walk_resource_tree
    # Get to the root of the resource collection
    rc = run_context.parent_run_context || run_context
    rc = rc.parent_run_context while rc.parent_run_context

    package_collection = package_name_array
    package_collection += nested_package_resources(rc.resource_collection)
  else
    package_collection = package_name_array
  end
  # downcase the array and uniq.  sorted for easier testing...
  package_collection.uniq.sort.filter { |pkg| !ignore_list.include?(pkg) }
end
define_resource_requirements() click to toggle source
# File lib/chef/provider/package/chocolatey.rb, line 64
def define_resource_requirements
  super

  # The check that Chocolatey is installed is in #choco_exe.

  # Chocolatey source property points to an alternate feed
  # and not a package specific alternate source like other providers
  # so we want to assert candidates exist for the alternate source
  requirements.assert(:upgrade, :install) do |a|
    a.assertion { candidates_exist_for_all_uninstalled? }
    a.failure_message(Chef::Exceptions::Package, "No candidate version available for #{packages_missing_candidates.join(", ")}")
    a.whyrun("Assuming a repository that offers #{packages_missing_candidates.join(", ")} would have been configured")
  end
end
get_choco_version() click to toggle source
# File lib/chef/provider/package/chocolatey.rb, line 146
def get_choco_version
  # We need a different way to get the version than by simply calling "choco --version".
  # If the license file is installed (for business customers) but not the Chocolatey.Extension (because you're using the choco resource to install it)
  # then you get a license error. This method bypasses that by getting the version from the exe directly instead of invoking it.
  # deprecated: @get_choco_version ||= powershell_exec!("#{choco_exe} --version").result
  @get_choco_version ||= powershell_exec!("Get-ItemProperty #{choco_exe} | select-object -expandproperty versioninfo | select-object -expandproperty productversion").result
end
install_package(names, versions) click to toggle source

Install multiple packages via choco.exe

@param names [Array<String>] array of package names to install @param versions [Array<String>] array of versions to install

# File lib/chef/provider/package/chocolatey.rb, line 91
def install_package(names, versions)
  name_versions_to_install = desired_name_versions.select { |n, v| lowercase_names(names).include?(n) }

  name_nil_versions = name_versions_to_install.select { |n, v| v.nil? }
  name_has_versions = name_versions_to_install.compact

  # choco does not support installing multiple packages with version pins
  name_has_versions.each do |name, version|
    choco_command("install", "-y", "--version", version, cmd_args, name)
  end

  # but we can do all the ones without version pins at once
  unless name_nil_versions.empty?
    cmd_names = name_nil_versions.keys
    choco_command("install", "-y", cmd_args, *cmd_names)
  end
end
invalidate_cache() click to toggle source

invalidate cache for testing purposes

# File lib/chef/provider/package/chocolatey.rb, line 162
def invalidate_cache
  @@choco_config = nil
end
load_current_resource() click to toggle source

Responsible for building the current_resource.

@return [Chef::Resource::ChocolateyPackage] the current_resource

# File lib/chef/provider/package/chocolatey.rb, line 53
def load_current_resource
  @current_resource = Chef::Resource::ChocolateyPackage.new(new_resource.name)
  current_resource.package_name(new_resource.package_name)
  current_resource.version(build_current_versions)
  # Ensure that we have a working chocolatey executable - this used to be
  # covered off by loading the resource, but since that's no longer required,
  # we're going to put a quick check here to fail early!
  choco_exe
  current_resource
end
purge_package(names, versions)

Choco does not have dpkg’s distinction between purge and remove

Alias for: remove_package
query_command() click to toggle source

Choco V2 uses ‘Search’ for remote repositories and ‘List’ for local packages

# File lib/chef/provider/package/chocolatey.rb, line 155
def query_command
  return "list" if get_choco_version.match?(/^1/)

  "search"
end
remove_package(names, versions) click to toggle source

Remove multiple packages via choco.exe

@param names [Array<String>] array of package names to install @param versions [Array<String>] array of versions to install

# File lib/chef/provider/package/chocolatey.rb, line 135
def remove_package(names, versions)
  choco_command("uninstall", "-y", cmd_args(include_source: false), *names)
end
Also aliased as: purge_package
upgrade_package(names, versions) click to toggle source

Upgrade multiple packages via choco.exe

@param names [Array<String>] array of package names to install @param versions [Array<String>] array of versions to install

# File lib/chef/provider/package/chocolatey.rb, line 113
def upgrade_package(names, versions)
  name_versions_to_install = desired_name_versions.select { |n, v| lowercase_names(names).include?(n) }

  name_nil_versions = name_versions_to_install.select { |n, v| v.nil? }
  name_has_versions = name_versions_to_install.compact

  # choco does not support installing multiple packages with version pins
  name_has_versions.each do |name, version|
    choco_command("upgrade", "-y", "--version", version, cmd_args, name)
  end

  # but we can do all the ones without version pins at once
  unless name_nil_versions.empty?
    cmd_names = name_nil_versions.keys
    choco_command("upgrade", "-y", cmd_args, *cmd_names)
  end
end

Private Instance Methods

actual_config() click to toggle source

Cache the configuration in order to ensure that we can check our package cache is valid for a run

# File lib/chef/provider/package/chocolatey.rb, line 221
def actual_config
  config_path = ::File.join("#{choco_install_path}", "config", "chocolatey.config")
  if ::File.exist?(config_path)
    return ::File.read(config_path)
  end

  nil
end
available_packages() click to toggle source

Available packages in chocolatey as a Hash of names mapped to versions If pinning a package to a specific version, filter out all non matching versions (names are downcased for case-insensitive matching)

@return [Hash] name-to-version mapping of available packages

# File lib/chef/provider/package/chocolatey.rb, line 327
def available_packages
  return @available_packages unless @available_packages.nil?

  # @available_packages is per object - each resource is an object, meaning if you
  # have a LOT of chocolatey package installs, then this quickly gets very slow.
  # So we use @@choco_available_packages instead - BUT it's important to ensure that
  # the cache is valid before you do this.  There are two cache items that can change:
  # a) the sources - we check this with cache_is_valid?
  if cache_is_valid? && @@choco_available_packages.is_a?(Hash) &&
      @@choco_available_packages[new_resource.list_options]

    # Ensure we have the package names, or else double check...
    need_redo = false
    package_name_array.each do |pkg|
      need_redo = true unless @@choco_available_packages[new_resource.list_options][pkg.downcase]
    end
    return @@choco_available_packages[new_resource.list_options] unless need_redo
  end
  if new_resource.list_options
    Chef::Log.info("Fetching chocolatey package list with options #{new_resource.list_options.inspect}")
  else
    Chef::Log.info("Fetching chocolatey package list")
  end

  # Only reset the array if the cache is invalid - if we're just augmenting it, don't
  # clear it
  @@choco_available_packages = {} if @@choco_available_packages.nil? || !cache_is_valid?
  if @@choco_available_packages[new_resource.list_options].nil?
    @@choco_available_packages[new_resource.list_options] = {}
  end

  # This would previously grab 25 packages at a time, which previously worked - however,
  # upstream changed and it turns out this was only working by accident - see
  # https://github.com/chocolatey/choco/issues/2116 for this.  So the TL;DR ends up
  # being that this can be re-enabled when the chocolatey server actually supports an
  # or operator.  So it makes sense to leave the logic here for this split, while we
  # work with upstream to get this to be a working feature there
  #
  # Foot guns: there is a --id-starts-with for chocolatey, which you'd think would work,
  # but that actually fails on public chocolatey as well, because it seems to do the filtering
  # locally. Which means it too will omit a lot of results (this is also corroborated by
  # the 2116 issue above).
  #
  # collect_package_requests, however, continues to be useful here because it filters
  # the already cached things from the list.  However, for now it will no longer walk the
  # resource tree until 2116 can be sorted out.  When we regain that ability, we should
  # re-evaluate this, since it does save a LOT of API requests!
  collect_package_requests(
    ignore_list: @@choco_available_packages[new_resource.list_options].keys
  ).each do |pkg_set|
    available_versions =
      begin
      cmd = [ query_command, "-r" ]

      # Chocolatey doesn't actually take a wildcard for this query, however
      # it will return all packages when using '*' as a query
      unless pkg_set == "*"
        cmd << pkg_set
      end
      cmd += common_options
      cmd.push( new_resource.list_options ) if new_resource.list_options

      Chef::Log.debug("Choco List Command: #{cmd}")

      raw = parse_list_output(*cmd)
      raw.keys.each_with_object({}) do |name, available|
        available[name] = desired_name_versions[name] || raw[name]
      end
    end
    @@choco_available_packages[new_resource.list_options].merge!(available_versions)
  end
  # Mark the cache as valid, with the required metadata
  set_package_cache
  # Why both?  So when we fail to find a package once, we don't try on every
  # retry, even though it would be reasonable to do so if queried in another
  # resource (because the chocolatey configuration may well have changed!)
  @available_packages = @@choco_available_packages[new_resource.list_options]
end
build_candidate_versions() click to toggle source

Use the available_packages Hash helper to create an array suitable for using in candidate_version

@return [Array] list of candidate_version, same index as new_resource.package_name/version

# File lib/chef/provider/package/chocolatey.rb, line 276
def build_candidate_versions
  new_resource.package_name.map do |package_name|
    available_packages[package_name.downcase]
  end
end
build_current_versions() click to toggle source

Use the installed_packages Hash helper to create an array suitable for using in current_resource.version

@return [Array] list of candidate_version, same index as new_resource.package_name/version

# File lib/chef/provider/package/chocolatey.rb, line 286
def build_current_versions
  new_resource.package_name.map do |package_name|
    installed_packages[package_name.downcase]
  end
end
choco_command(*args) click to toggle source

Helper to dispatch a choco command through shell_out using the timeout set on the new resource, with nice command formatting.

@param args [String] variable number of string arguments @return [Mixlib::ShellOut] object returned from shell_out!

# File lib/chef/provider/package/chocolatey.rb, line 268
def choco_command(*args)
  shell_out!(choco_exe, *args, returns: new_resource.returns)
end
choco_exe() click to toggle source

Magic to find where chocolatey is installed in the system, and to return the full path of choco.exe

@return [String] full path of choco.exe

# File lib/chef/provider/package/chocolatey.rb, line 239
def choco_exe
  @choco_exe ||= begin
      # if this check is in #define_resource_requirements, it won't get
      # run before choco.exe gets called from #load_current_resource.
      exe_path = ::File.join(choco_install_path, "bin", "choco.exe")
      raise Chef::Exceptions::MissingLibrary, CHOCO_MISSING_MSG unless ::File.exist?(exe_path)

      exe_path
    end
end
choco_install_path() click to toggle source

lets us mock out an incorrect value for testing.

# File lib/chef/provider/package/chocolatey.rb, line 251
def choco_install_path
  @choco_install_path ||= begin
    result = powershell_exec!(PATHFINDING_POWERSHELL_COMMAND).result
    result = "" if result.empty?
    result
  end
end
choco_lib_path() click to toggle source
# File lib/chef/provider/package/chocolatey.rb, line 259
def choco_lib_path
  ::File.join(choco_install_path, "lib")
end
cmd_args(include_source: true) click to toggle source

Helper to construct optional args out of new_resource

@param include_source [Boolean] should the source parameter be added @return [String] options from new_resource or empty string

# File lib/chef/provider/package/chocolatey.rb, line 316
def cmd_args(include_source: true)
  cmd_args = new_resource.options.is_a?(String) ? command_line_to_argv_w_helper(new_resource.options) : Array(new_resource.options)
  cmd_args += common_options(include_source: include_source)
  cmd_args
end
common_options(include_source: true) click to toggle source
# File lib/chef/provider/package/chocolatey.rb, line 486
def common_options(include_source: true)
  args = []
  args.push( [ "-source", new_resource.source ] ) if new_resource.source && include_source
  args.push( [ "--user", new_resource.user ] ) if new_resource.user
  args.push( [ "--password", new_resource.password ]) if new_resource.password
  args
end
desired_name_versions() click to toggle source

Helper to construct Hash of names-to-versions, requested on the new_resource. If new_resource.version is nil, then all values will be nil.

@return [Hash] Mapping of requested names to versions

# File lib/chef/provider/package/chocolatey.rb, line 296
def desired_name_versions
  desired_versions = new_resource.version || new_resource.package_name.map { nil }
  Hash[*lowercase_names(new_resource.package_name).zip(desired_versions).flatten]
end
fetch_package_versions(base_dir, target_dirs, targets) click to toggle source

Fetch the local package versions from chocolatey

# File lib/chef/provider/package/chocolatey.rb, line 495
def fetch_package_versions(base_dir, target_dirs, targets)
  pkg_versions = {}
  targets = [targets] if targets.is_a?(String)
  target_dirs.each do |dir|
    pkg_versions.merge!(get_pkg_data(::File.join(base_dir, dir)))
    # return early if we found the single package version we were looking for
    return pkg_versions if targets.length == 1 && pkg_versions[targets.first]
  end
  pkg_versions
end
get_local_pkg_dirs(base_dir) click to toggle source

Grab the nupkg folder list

# File lib/chef/provider/package/chocolatey.rb, line 454
def get_local_pkg_dirs(base_dir)
  return [] unless Dir.exist?(base_dir)

  Dir.entries(base_dir).select do |dir|
    ::File.directory?(::File.join(base_dir, dir)) && !dir.start_with?(".")
  end
end
get_pkg_data(path) click to toggle source

Grab the locally installed packages from the nupkg list rather than shelling out to chocolatey

# File lib/chef/provider/package/chocolatey.rb, line 508
def get_pkg_data(path)
  t = ::File.join(path, "*.nupkg").gsub("\\", "/")
  targets = Dir.glob(t)

  # Extract package version from the first nuspec file in this nupkg
  targets.each do |target|
    Zip::File.open(target) do |zip_file|
      zip_file.each do |entry|
        next unless entry.name.end_with?(".nuspec")

        f = entry.get_input_stream
        doc = REXML::Document.new(f.read.to_s)
        f.close
        id = doc.elements["package/metadata/id"]
        version = doc.elements["package/metadata/version"]
        return { id.text.to_s.downcase => version.text } if id && version
      end
    end
  end
  {}
rescue StandardError => e
  Chef::Log.warn("Failed to get package info for #{path}: #{e}")
  {}
end
installed_packages() click to toggle source

Installed packages in chocolatey as a Hash of names mapped to versions (names are downcased for case-insensitive matching). Depending on the user preference, we get these either from the local database, or from the choco list command

@return [Hash] name-to-version mapping of installed packages

# File lib/chef/provider/package/chocolatey.rb, line 412
def installed_packages
  # Logic here must be either use_choco_list is false _and_ always_use_choco_list is
  # falsy, since the global overrides the local
  if new_resource.use_choco_list == false && !Chef::Config[:always_use_choco_list]
    installed_packages_via_disk
  else
    installed_packages_via_choco
  end
end
installed_packages_via_choco() click to toggle source

Beginning with Choco 2.0, “list” returns local packages only while “search” returns packages from external package sources

@return [Hash] name-to-version mapping of installed packages

# File lib/chef/provider/package/chocolatey.rb, line 425
def installed_packages_via_choco
  @installed_packages ||= Hash[*parse_list_output("list", "-l", "-r").flatten]
  @installed_packages
end
installed_packages_via_disk() click to toggle source

Return packages sourced from the local disk - because this doesn’t have shell out overhead, this ends up being a significant performance win vs calling choco list

@return [Hash] name-to-version mapping of installed packages

# File lib/chef/provider/package/chocolatey.rb, line 435
def installed_packages_via_disk
  @installed_packages ||= begin
    targets = new_resource.name
    target_dirs = []
    # If we're using a single package name, have it at the head of the list
    # so we can get more performance.  In either case, the
    # array is filled by the call to `get_local_pkg_dirs` below - but
    # that contains all possible package folders, and so we push our
    # guess to the front as an optimization.
    target_dirs << targets.first.downcase if targets.length == 1
    if targets.is_a?(String)
      target_dirs << targets.downcase
    end
    target_dirs += get_local_pkg_dirs(choco_lib_path)
    fetch_package_versions(choco_lib_path, target_dirs, targets)
  end
end
lowercase_names(names) click to toggle source

Helper to downcase all names in an array

@param names [Array] original mixed case names @return [Array] same names in lower case

# File lib/chef/provider/package/chocolatey.rb, line 482
def lowercase_names(names)
  names.map(&:downcase)
end
nested_package_resources(res) click to toggle source
# File lib/chef/provider/package/chocolatey.rb, line 301
def nested_package_resources(res)
  package_collection = []
  res.each do |child_res|
    package_collection += nested_package_resources(child_res.resources)
    next unless child_res.is_a?(Chef::Resource::ChocolateyPackage)

    package_collection += child_res.package_name.flatten
  end
  package_collection
end
parse_list_output(*args) click to toggle source

Helper to convert choco.exe list output to a Hash (names are downcased for case-insensitive matching)

@param cmd [String] command to run @return [Hash] list output converted to ruby Hash

# File lib/chef/provider/package/chocolatey.rb, line 467
def parse_list_output(*args)
  hash = {}
  choco_command(*args).stdout.each_line do |line|
    next if line.start_with?("Chocolatey v")

    name, version = line.split("|")
    hash[name.downcase] = version&.chomp
  end
  hash
end
set_package_cache() click to toggle source

update the validity of the package cache

# File lib/chef/provider/package/chocolatey.rb, line 231
def set_package_cache
  @@choco_config = actual_config
end
version_compare(v1, v2) click to toggle source
# File lib/chef/provider/package/chocolatey.rb, line 208
def version_compare(v1, v2)
  if v1 == "latest" || v2 == "latest"
    return 0
  end

  gem_v1 = Gem::Version.new(v1)
  gem_v2 = Gem::Version.new(v2)

  gem_v1 <=> gem_v2
end