ptomulik/puppet-portsng

View on GitHub
lib/puppet/provider/package/portsng.rb

Summary

Maintainability
C
1 day
Test Coverage
# Some ancient versions of puppet were not adding modules to $LOAD_PATH
%w(vash portsutil backports).each do |p|
  dir = File.join(File.dirname(__FILE__), "../../../../../#{p}/lib")
  dir = File.expand_path(dir)
  $LOAD_PATH.unshift(dir) if !$LOAD_PATH.include?(dir) && File.directory?(dir)
end

require 'puppet/backport/type/package/package_settings'
require 'puppet/backport/type/package/uninstall_options'
require 'puppet/provider/package'

# rubocop: disable BlockLength
Puppet::Type.type(:package).provide :portsng,
                                    :parent => Puppet::Provider::Package do
  desc "Support for FreeBSD's ports. Note that this mixes packages and ports.

  `install_options` are passed to `portupgrade` command when installing,
  reinstalling or upgrading packages. You should always include
  `['-M','BATCH=yes']` options in your custom `install_options`. Some CLI flags
  are prepended internally to CLI and some flags given by user are internally
  removed when performing install, reinstall or upgrade actions. Install
  always prepends `-N` and removes `-R` and `-f` if provided by user. Reinstall
  always prepends `-f` and removes `-N` flag if present. Upgrade always removes
  `-N` and `-f` if present in install_options.

  `uninstall_options` are passed to uninstall command. When the target system
  uses (old) `pkg_xxx` tools to manage packages, these options are passed to
  `pkg_deinstall` command. When the target system uses `pkgng` tools, then
  `uninstall_options` are passed to `pkg` command. If you define custom options
  for `pkg` (pkgng toolstack), you should always include the `-y` option (see
  pkg-delete(8) for details). Typical use case for `uninstall_options` is
  uninstalling packages recursively, that is uninstalling a package and all the
  other packages depending on this one. For `pkg_deinstall` the `%w(-r)` does
  the job and for `pkgng` it's achieved with `%w(-y -R)`.

  `package_settings` shall be a hash with port's option names as keys (all
  uppercase) and boolean values. This parameter defines options that you would
  normally set with make config command (the blue ncurses interface). Here is
  an example:

      package { 'www/apache22':
        ensure => present,
        package_settings => { 'SUEXEC' => true }
      }


  The options are written to `/var/db/ports/*/options.local` files (one file
  per package). For old `pkg_xxx` toolstack they are synchronized with what is
  found in `/var/db/ports/*/options{,.local}` files (possibly several files
  files per package). For the new `pkgng` system, they're synchronized with
  options returned by `pkg query`. If `package_settings` of an already installed
  package are out of sync with the ones prescribed in puppet manifests, the
  package gets reinstalled with the options taken from puppet manifests.
  "
  require 'puppet/util/ptomulik/package/ports'
  require 'puppet/util/ptomulik/package/ports/options'
  extend Puppet::Util::PTomulik::Package::Ports

  # Default options for {#install} method.
  self::DEFAULT_INSTALL_OPTIONS = %w(-N -M BATCH=yes).freeze
  # Default options for {#reinstall} method.
  self::DEFAULT_REINSTALL_OPTIONS = %w(-r -f -M BATCH=yes).freeze
  # Default options for {#update} method.
  self::DEFAULT_UPGRADE_OPTIONS = %w(-R -M BATCH=yes).freeze

  # Detect whether the OS uses old pkg or the new pkgng.
  if pkgng_active? :pkg => '/usr/local/sbin/pkg'
    commands :portuninstall => '/usr/local/sbin/pkg',
             :pkg => '/usr/local/sbin/pkg'
    self::DEFAULT_UNINSTALL_OPTIONS = %w(delete -y).freeze
  else
    commands :portuninstall => '/usr/local/sbin/pkg_deinstall'
    self::DEFAULT_UNINSTALL_OPTIONS = %w().freeze
  end
  debug "Selecting '#{command(:portuninstall)}' command as package uninstaller"

  commands :portupgrade => '/usr/local/sbin/portupgrade',
           :portversion => '/usr/local/sbin/portversion',
           :make =>        '/usr/bin/make'

  has_feature :install_options
  has_feature :uninstall_options

  # I hate ports
  %w(INTERACTIVE UNAME).each do |var|
    ENV.delete(var) if ENV.include?(var)
  end

  # note, portsdir and port_dbdir are defined in module
  # Puppet::Util::PTomulik::Package::Ports::Functions
  confine :exists => [portsdir, port_dbdir]

  def pkgng_active?
    self.class.pkgng_active?
  end

  def self.instances(names = nil)
    records = find_packages(names, instances_fields)
    instances_from_package_records(records, query_build_options)
  end

  def self.prefetch(packages)
    # prefetch already installed packages
    absent = prefetch_present(packages)
    # also prefetch not installed ports to save time; this way we perform
    # only two or three calls to `make search` (for up to 60 packages) instead
    # of 3xN calls (in query()) for N packages
    prefetch_absent(packages, absent)
  end

  def self.instances_fields
    fields = Puppet::Util::PTomulik::Package::Ports::PkgRecord.default_fields
    fields -= [:options] if pkgng_active?
    fields
  end
  private_class_method :instances_fields

  # return build options for all installed packages (if pkgng is active)
  def self.query_build_options
    if pkgng_active?
      # here, with pkgng we have more reliable and efficient way to retrieve
      # build options
      options_class = Puppet::Util::PTomulik::Package::Ports::Options
      options_class.query_pkgng('%o', nil, :pkg => command(:pkg))
    else
      {}
    end
  end
  private_class_method :query_build_options

  def self.split_record_fcn(names)
    if names
      lambda { |r| [r[1][:pkgname], r[1]] }
    else
      lambda { |r| [r[:pkgname], r] }
    end
  end
  private_class_method :split_record_fcn

  def self.find_packages(names, fields)
    split_record = split_record_fcn(names)
    records = {}
    # find installed packages
    search_packages(names, fields) do |record|
      name, record = split_record.call(record)
      records[name] ||= []
      records[name] << record
    end
    records
  end
  private_class_method :find_packages

  def self.instance_from_package_record(record, options)
    portorigin = record[:portorigin]
    # if portorigin is unavailable, use pkgname to identify the package,
    # this allows to at least uninstall packages that are currently
    # installed but their ports were removed from ports tree
    new(:name => portorigin || record[:pkgname],
        :ensure => record[:pkgversion],
        :package_settings => options[portorigin] || record[:options] || {},
        :provider => name).assign_port_attributes(record)
  end
  private_class_method :instance_from_package_record

  def self.instances_from_package_records(records, options)
    packages = []
    with_unique('installed ports', records) do |pkgname, rec|
      unless rec[:portorigin] && ['<', '=', '>'].include?(rec[:portstatus])
        rec.delete(:portorigin) if rec[:portorigin]
        warning "Could not find port for installed package '#{pkgname}'. " \
                'Build options and upgrades will not work for this package.'
      end
      packages << instance_from_package_record(rec, options)
    end
    packages
  end
  private_class_method :instances_from_package_records

  def self.find_ports(names)
    records = {}
    search_ports(names) do |name, record|
      records[name] ||= []
      records[name] << record
    end
    records
  end
  private_class_method :find_ports

  def self.instance_from_absent_port_record(record)
    portorigin = record[:portorigin]
    new(:name => portorigin, :ensure => :absent).assign_port_attributes(record)
  end
  private_class_method :instance_from_absent_port_record

  # prefetch already installed packages and return remaining packages
  def self.prefetch_present(packages)
    absent = packages.keys
    instances.each do |prov|
      keys = [prov.name, prov.portorigin, prov.pkgname, prov.portname]
      pkg = keys.map { |x| packages[x] }.find { |x| x }
      next unless pkg
      absent -= keys
      pkg.provider = prov
    end
    absent
  end
  private_class_method :prefetch_present

  def self.prefetch_absent(packages, absent)
    with_unique('ports', find_ports(absent)) do |name, record|
      packages[name].provider = instance_from_absent_port_record(record)
    end
  end
  private_class_method :prefetch_absent

  def self.with_unique(what, records)
    records.each do |name, array|
      record = array.last
      if (len = array.length) > 1
        warning "Found #{len} #{what} named '#{name}': " \
                "#{array.map { |r| "'#{r[:portorigin]}'" }.join(', ')}. " \
                "Only '#{record[:portorigin]}' will be ensured."
      end
      yield name, record
    end
  end
  private_class_method :with_unique

  self::PORT_ATTRIBUTES = [
    :pkgname,
    :portorigin,
    :portname,
    :portstatus,
    :portinfo,
    :options_file,
    :options_files
  ].freeze

  self::PORT_ATTRIBUTES.each do |attr|
    define_method(attr) do
      var = instance_variable_get("@#{attr}".intern)
      unless var
        raise Puppet::Error,
              "Attribute '#{attr}' not assigned for package '#{name}'."
      end
      var
    end
  end

  # assign attributes from hash (but only these listed in PORT_ATTRIBUTES)
  def assign_port_attributes(record)
    (record.keys & self.class::PORT_ATTRIBUTES).each do |key|
      instance_variable_set("@#{key}".intern, record[key])
    end
    self
  end

  def validate_package_setting(key, value)
    options_class = Puppet::Util::PTomulik::Package::Ports::Options
    unless options_class.option_name?(key)
      raise ArgumentError, "#{key.inspect} is not a valid option name (for" \
                           ' $package_settings)'
    end
    unless options_class.option_value?(value)
      raise ArgumentError, "#{value.inspect} is not a valid option value (for" \
                           ' $package_settings)'
    end
    true
  end
  private :validate_package_setting

  # needed by Puppet::Type::Package
  def package_settings_validate(opts)
    return true unless opts # options not defined
    options_class = Puppet::Util::PTomulik::Package::Ports::Options
    unless opts.is_a?(Hash) || opts.is_a?(options_class)
      raise ArgumentError, "#{opts.inspect} of type #{opts.class} is not an " \
                           'options Hash (for $package_settings)'
    end
    opts.each { |k, v| validate_package_setting(k, v) }
    true
  end

  # needed by Puppet::Type::Package
  def package_settings_munge(opts)
    if opts.is_a?(Puppet::Util::PTomulik::Package::Ports::Options)
      opts
    else
      Puppet::Util::PTomulik::Package::Ports::Options[opts || {}]
    end
  end

  # needed by Puppet::Type::Package
  def package_settings_insync?(should, is)
    unless should.is_a?(Puppet::Util::PTomulik::Package::Ports::Options) &&
           is.is_a?(Puppet::Util::PTomulik::Package::Ports::Options)
      return false
    end
    is.select { |k, _| should.keys.include? k } == should
  end

  # needed by Puppet::Type::Package
  def package_settings_should_to_s(_should, newvalue)
    if newvalue.is_a?(Puppet::Util::PTomulik::Package::Ports::Options)
      Puppet::Util::PTomulik::Package::Ports::Options[newvalue.sort].inspect
    else
      newvalue.inspect
    end
  end

  # needed by Puppet::Type::Package
  def package_settings_is_to_s(should, currentvalue)
    if currentvalue.is_a?(Puppet::Util::PTomulik::Package::Ports::Options)
      hash = currentvalue.select { |k, _| should.keys.include? k }.sort
      Puppet::Util::PTomulik::Package::Ports::Options[hash].inspect
    else
      currentvalue.inspect
    end
  end

  # Interface method required by package resource type. Returns the current
  # value of package_settings property.
  def package_settings
    properties[:package_settings]
  end

  # Reinstall package to deploy (new) build options.
  def package_settings=(opts)
    reinstall(opts)
  end

  def sync_package_settings(should)
    return unless should
    is = properties[:package_settings]
    return if package_settings_insync?(should, is)
    syntax = self.class.options_files_default_syntax
    should.save(options_file, :pkgname => pkgname, :syntax => syntax)
  end
  private :sync_package_settings

  def revert_package_settings
    return unless (options = properties[:package_settings])
    debug "Reverting options in #{options_file}"
    syntax = self.class.options_files_default_syntax
    options.save(options_file, :pkgname => pkgname, :syntax => syntax)
  end
  private :revert_package_settings

  # Return portupgrade's CLI options for use within the {#install} method.
  def install_options
    # In an ideal world we would have all these parameters independent:
    # install_options, reinstall_options, upgrade_options, uninstall_options.
    # In this world we must live with install_options and uninstall_options
    # only.
    ops = resource[:install_options]
    # We always add -N to command line to indicate, that we want to install new
    # package only when it's not installed. This idea is inherited from
    # original implementation of ports provider.
    # We always remove -R and -f from command line, as these options have
    # no clear meaning when -N is used (either, they have no effect with -R or
    # they can mess-up your OS - I haven't checked this).
    prepare_options(ops, self.class::DEFAULT_INSTALL_OPTIONS, %w(-N), %w(-R -f))
  end

  # Return portupgrade's CLI options for use within the {#reinstall} method.
  def reinstall_options
    ops = resource[:install_options]
    # We always remove -N from command line, as this flag breaks the upgrade
    # procedure (-N indicates that one wants to install new package which is
    # currently not installed, or to skip installation if it's installed; the
    # reinstall method is invoked on already installed packages only).
    # We always add -f to command line, to not silently skip reinstall (without
    # this reinstalls are silently discarded)
    prepare_options(ops, self.class::DEFAULT_REINSTALL_OPTIONS, %w(-f), %w(-N))
  end

  # Return portupgrade's CLI options for use within the {#update} method.
  def upgrade_options
    ops = resource[:install_options]
    # We always remove -N from command line, as this flag breaks the upgrade
    # procedure (-N indicates that one wants to install package which is not
    # currently installed, or to skip installation if it's installed; the
    # upgrade method is invoked on already installed packages only).
    # We always remove -f from command line, as the upgrade procedure shouldn't
    # depend on it (upgrade should only be used to install newer versions,
    # which must work without -f)
    prepare_options(ops, self.class::DEFAULT_UPGRADE_OPTIONS, %w(), %w(-f -N))
  end

  # Return portuninstall's CLI options for use within the {#uninstall} method.
  def uninstall_options
    # For pkgng we always prepend the 'delete' command to options.
    ops = resource[:uninstall_options]
    if pkgng_active?
      prepare_options(ops, self.class::DEFAULT_UNINSTALL_OPTIONS, %w(delete))
    else
      prepare_options(ops, self.class::DEFAULT_UNINSTALL_OPTIONS)
    end
  end

  # Prepare options for install, reinstall, upgrade and uninstall methods.
  #
  # @param options [Array|nil]
  # @param defaults [Array] default flags used when options are not provided,
  # @param extra [Array] extra flags added to user-defined options,
  # @param deny [Array] flags that must be removed from user-defined options,
  # @return [Array] modified options
  #
  # Returns defaults if options are not provided by user. If options are
  # provided, handle the '{option => value}' pairs, flatten options array
  # append extra flags defined by caller and remove denied flags defined by the
  # caller.
  #
  def prepare_options(options, defaults, extra = [], deny = [])
    return defaults.dup unless options

    # handle {option => value} hashes and flatten nested arrays
    options = options.collect do |x|
      x.is_a?(Hash) ? x.keys.sort.collect { |k| "#{k}=#{x[k]}" } : x
    end.flatten

    # add some flags we think are mandatory for the given operation
    extra.each { |f| options.unshift(f) unless options.include?(f) }
    options -= deny
  end

  # For internal use only
  def do_portupgrade(name, args, package_settings)
    cmd = args + [name]
    begin
      sync_package_settings(package_settings)
      if portupgrade(*cmd) =~ /\*\* No such /
        raise Puppet::ExecutionFailure, "Could not find package #{name}"
      end
    rescue
      revert_package_settings
      raise
    end
  end
  private :do_portupgrade

  # install new package (only if it's not installed).
  def install
    # we prefetched also not installed ports so @portorigin may be present
    name = @portorigin || resource[:name]
    do_portupgrade name, install_options, resource[:package_settings]
  end

  # reinstall already installed package with new options.
  def reinstall(options)
    if @portorigin
      do_portupgrade portorigin, reinstall_options, options
    else
      warning "Could not reinstall package '#{name}' which has no port origin."
    end
  end

  # upgrade already installed package.
  def update
    if properties[:ensure] == :absent
      install
    elsif @portorigin
      do_portupgrade portorigin, upgrade_options, resource[:package_settings]
    else
      warning "Could not upgrade package '#{name}' which has no port origin."
    end
  end

  # uninstall already installed package
  def uninstall
    cmd = uninstall_options + [pkgname]
    portuninstall(*cmd)
  end

  # If there are multiple packages, we only use the last one
  # rubocop: disable MethodLength, AbcSize, CyclomaticComplexity
  def latest
    # If there's no "latest" version, we just return a placeholder
    result = :latest
    oldversion = properties[:ensure]
    case portstatus
    when '>', '='
      result = oldversion
    when '<'
      raise Puppet::Error, "Could not match version info #{portinfo.inspect}." \
        unless (m = portinfo.match(/\((\w+) has (.+)\)/))
      source, newversion = m[1, 2]
      debug "Newer version in #{source}"
      result = newversion
    when '?'
      warning "The installed package '#{pkgname}' does not appear in the " \
              'ports database nor does its port directory exist.'
    when '!'
      warning "The installed package '#{pkgname}' does not appear in the " \
              'ports database, the port directory actually exists, but the ' \
              'latest version number cannot be obtained.'
    when '#'
      warning "The installed package '#{pkgname}' does not have an origin " \
              'recorded.'
    else
      warning "Invalid status flag #{portstatus.inspect} for package " \
              "'#{pkgname}' (returned by portversion command)."
    end
    result
  end
  # rubocop: enable MethodLength, AbcSize, CyclomaticComplexity

  def query
    # support names, portorigin, pkgname and portname
    (inst = self.class.instances([name]).last) ? inst.properties : nil
  end
end
# rubocop: enable BlockLength