introduce dependency resolution

This commit is the foundation of the dependency resolution as described in https://github.com/chef/inspec/issues/888 .

It currently only works with local dependencies, as seen in the example inheritance profile.

Tests and full resolution are coming next on the path to an MVP implementation.
This commit is contained in:
Dominik Richter 2016-04-27 04:25:38 -04:00 committed by Christoph Hartmann
parent d4850a072c
commit 7e569669aa
7 changed files with 337 additions and 22 deletions

View file

@ -8,3 +8,6 @@ summary: Demonstrates the use of InSpec profile inheritance
version: 1.0.0
supports:
- os-family: unix
depends:
- name: profile
path: ../profile

View file

@ -34,6 +34,7 @@ Gem::Specification.new do |spec|
spec.add_dependency 'rspec-its', '~> 1.2'
spec.add_dependency 'pry', '~> 0'
spec.add_dependency 'hashie', '~> 3.4'
spec.add_dependency 'molinillo', '~> 0.5'
spec.add_development_dependency 'mocha', '~> 1.1'
end

307
lib/inspec/dependencies.rb Normal file
View file

@ -0,0 +1,307 @@
# encoding: utf-8
# author: Dominik Richter
# author: Christoph Hartmann
require 'logger'
require 'fileutils'
require 'molinillo'
require 'inspec/errors'
module Inspec
class Resolver
def self.resolve(requirements, vendor_index, cwd, opts = {})
reqs = requirements.map do |req|
Requirement.from_metadata(req, cwd: cwd) ||
fail("Cannot initialize dependency: #{req}")
end
new(vendor_index, opts).resolve(reqs)
end
def initialize(vendor_index, opts = {})
@logger = opts[:logger] || Logger.new(nil)
@debug_mode = false # TODO: hardcoded for now, grab from options
@vendor_index = vendor_index
@resolver = Molinillo::Resolver.new(self, self)
@search_cache = {}
end
# Resolve requirements.
#
# @param requirements [Array(Inspec::requirement)] Array of requirements
# @return [Array(String)] list of resolved dependency paths
def resolve(requirements)
requirements.each(&:pull)
@base_dep_graph = Molinillo::DependencyGraph.new
@dep_graph = @resolver.resolve(requirements, @base_dep_graph)
arr = @dep_graph.map(&:payload)
Hash[arr.map { |e| [e.name, e] }]
rescue Molinillo::VersionConflict => e
raise VersionConflict.new(e.conflicts.keys.uniq, e.message)
rescue Molinillo::CircularDependencyError => e
names = e.dependencies.sort_by(&:name).map { |d| "profile '#{d.name}'" }
raise CyclicDependencyError,
'Your profile has requirements that depend on each other, creating '\
"an infinite loop. Please remove #{names.count > 1 ? 'either ' : ''} "\
"#{names.join(' or ')} and try again."
end
# --------------------------------------------------------------------------
# SpecificationProvider
# Search for the specifications that match the given dependency.
# The specifications in the returned array will be considered in reverse
# order, so the latest version ought to be last.
# @note This method should be 'pure', i.e. the return value should depend
# only on the `dependency` parameter.
#
# @param [Object] dependency
# @return [Array<Object>] the specifications that satisfy the given
# `dependency`.
def search_for(dep)
unless dep.is_a?(Inspec::Requirement)
fail 'Internal error: Dependency resolver requires an Inspec::Requirement object for #search_for(dependency)'
end
@search_cache[dep] ||= uncached_search_for(dep)
end
def uncached_search_for(dep)
# pre-cached and specified dependencies
return [dep] unless dep.profile.nil?
results = @vendor_index.find(dep)
return [] unless results.any?
# TODO: load dep from vendor index
# vertex = @dep_graph.vertex_named(dep.name)
# locked_requirement = vertex.payload.requirement if vertex
fail NotImplementedError, "load dependency #{dep} from vendor index"
end
# Returns the dependencies of `specification`.
# @note This method should be 'pure', i.e. the return value should depend
# only on the `specification` parameter.
#
# @param [Object] specification
# @return [Array<Object>] the dependencies that are required by the given
# `specification`.
def dependencies_for(specification)
specification.profile.metadata.dependencies
end
# Determines whether the given `requirement` is satisfied by the given
# `spec`, in the context of the current `activated` dependency graph.
#
# @param [Object] requirement
# @param [DependencyGraph] activated the current dependency graph in the
# resolution process.
# @param [Object] spec
# @return [Boolean] whether `requirement` is satisfied by `spec` in the
# context of the current `activated` dependency graph.
def requirement_satisfied_by?(requirement, _activated, spec)
requirement.matches_spec?(spec) || spec.is_a?(Inspec::Profile)
end
# Returns the name for the given `dependency`.
# @note This method should be 'pure', i.e. the return value should depend
# only on the `dependency` parameter.
#
# @param [Object] dependency
# @return [String] the name for the given `dependency`.
def name_for(dependency)
unless dependency.is_a?(Inspec::Requirement)
fail 'Internal error: Dependency resolver requires an Inspec::Requirement object for #name_for(dependency)'
end
dependency.name
end
# @return [String] the name of the source of explicit dependencies, i.e.
# those passed to {Resolver#resolve} directly.
def name_for_explicit_dependency_source
'inspec.yml'
end
# @return [String] the name of the source of 'locked' dependencies, i.e.
# those passed to {Resolver#resolve} directly as the `base`
def name_for_locking_dependency_source
'inspec.lock'
end
# Sort dependencies so that the ones that are easiest to resolve are first.
# Easiest to resolve is (usually) defined by:
# 1) Is this dependency already activated?
# 2) How relaxed are the requirements?
# 3) Are there any conflicts for this dependency?
# 4) How many possibilities are there to satisfy this dependency?
#
# @param [Array<Object>] dependencies
# @param [DependencyGraph] activated the current dependency graph in the
# resolution process.
# @param [{String => Array<Conflict>}] conflicts
# @return [Array<Object>] a sorted copy of `dependencies`.
def sort_dependencies(dependencies, activated, conflicts)
dependencies.sort_by do |dependency|
name = name_for(dependency)
[
activated.vertex_named(name).payload ? 0 : 1,
# amount_constrained(dependency), # TODO
conflicts[name] ? 0 : 1,
# activated.vertex_named(name).payload ? 0 : search_for(dependency).count, # TODO
]
end
end
# Returns whether this dependency, which has no possible matching
# specifications, can safely be ignored.
#
# @param [Object] dependency
# @return [Boolean] whether this dependency can safely be skipped.
def allow_missing?(dependency)
# TODO
false
end
# --------------------------------------------------------------------------
# UI
include Molinillo::UI
# The {IO} object that should be used to print output. `STDOUT`, by default.
#
# @return [IO]
def output
self
end
def print(what = '')
@logger.info(what)
end
alias puts print
end
class Package
def initialize(path, version)
@path = path
@version = version
end
end
class VendorIndex
attr_reader :list, :path
def initialize(path)
@path = path
FileUtils.mkdir_p(path) unless File.directory?(path)
@list = Dir[File.join(path, '*')].map { |x| load_path(x) }
end
def find(_dependency)
# TODO
fail NotImplementedError, '#find(dependency) on VendorIndex seeks implementation.'
end
private
def load_path(_path)
# TODO
fail NotImplementedError, '#load_path(path) on VendorIndex wants to be implemented.'
end
end
class Requirement
attr_reader :name, :dep, :cwd, :opts
def initialize(name, dep, cwd, opts)
@name = name
@dep = Gem::Dependency.new(name, Gem::Requirement.new(Array(dep)), :runtime)
@opts = opts
@cwd = cwd
end
def matches_spec?(spec)
params = spec.profile.metadata.params
@dep.match?(params[:name], params[:version])
end
def pull
case
when @opts[:path] then pull_path(@opts[:path])
else
# TODO: should default to supermarket
fail 'You must specify the source of the dependency (for now...)'
end
end
def path
@path || pull
end
def profile
return nil if path.nil?
@profile ||= Inspec::Profile.for_target(path, {})
end
def self.from_metadata(dep, opts)
fail 'Cannot load empty dependency.' if dep.nil? || dep.empty?
name = dep[:name] || fail('You must provide a name for all dependencies')
version = dep[:version]
new(name, version, opts[:cwd], dep)
end
def to_s
@dep.to_s
end
private
def pull_path(path)
abspath = File.absolute_path(path, @cwd)
fail "Dependency path doesn't exist: #{path}" unless File.exist?(abspath)
fail "Dependency path isn't a folder: #{path}" unless File.directory?(abspath)
@path = abspath
true
end
end
class SupermarketDependency
def initialize(url, requirement)
@url = url
@requirement = requirement
end
def self.load(dep)
return nil if dep.nil?
sname = dep[:supermarket]
return nil if sname.nil?
surl = dep[:supermarket_url] || 'default_url...'
requirement = dep[:version]
url = surl + '/' + sname
new(url, requirement)
end
end
class Dependencies
attr_reader :list, :vendor_path
# initialize
#
# @param cwd [String] current working directory for relative path includes
# @param vendor_path [String] path which contains vendored dependencies
# @return [dependencies] this
def initialize(cwd, vendor_path)
@cwd = cwd
@vendor_path = vendor_path || File.join(Dir.home, '.inspec', 'cache')
@list = nil
end
# 1. Get dependencies, pull things to a local cache if necessary
# 2. Resolve dependencies
#
# @param dependencies [Gem::Dependency] list of dependencies
# @return [nil]
def vendor(dependencies)
return if dependencies.nil? || dependencies.empty?
@vendor_index ||= VendorIndex.new(@vendor_path)
@list = Resolver.resolve(dependencies, @vendor_index, @cwd)
end
end
end

View file

@ -6,12 +6,12 @@
module Inspec::DSL
def require_controls(id, &block)
opts = { profile_id: id, include_all: false, backend: @backend, conf: @conf }
opts = { profile_id: id, include_all: false, backend: @backend, conf: @conf, dependencies: @dependencies }
::Inspec::DSL.load_spec_files_for_profile(self, opts, &block)
end
def include_controls(id, &block)
opts = { profile_id: id, include_all: true, backend: @backend, conf: @conf }
opts = { profile_id: id, include_all: true, backend: @backend, conf: @conf, dependencies: @dependencies }
::Inspec::DSL.load_spec_files_for_profile(self, opts, &block)
end
@ -33,8 +33,9 @@ module Inspec::DSL
def self.load_spec_files_for_profile(bind_context, opts, &block)
# get all spec files
target = get_reference_profile(opts[:profile_id], opts[:conf])
profile = Inspec::Profile.for_target(target, opts)
target = opts[:dependencies].list[opts[:profile_id]] ||
fail("Can't find profile #{opts[:profile_id].inspect}, please add it as a dependency.")
profile = Inspec::Profile.for_target(target.path, opts)
context = load_profile_context(opts[:profile_id], profile, opts)
# if we don't want all the rules, then just make 1 pass to get all rule_IDs
@ -62,22 +63,6 @@ module Inspec::DSL
end
end
def self.get_reference_profile(id, opts)
profiles_path = opts['profiles_path'] ||
fail('You must supply a --profiles-path to inherit from other profiles.')
abs_path = File.expand_path(profiles_path.to_s)
unless File.directory? abs_path
fail("Cannot find profiles path #{abs_path}")
end
id_path = File.join(abs_path, id)
unless File.directory? id_path
fail("Cannot find referenced profile #{id} in #{id_path}")
end
id_path
end
def self.load_profile_context(id, profile, opts)
ctx = Inspec::ProfileContext.new(id, opts[:backend], opts[:conf])
profile.libraries.each do |path, content|

View file

@ -36,6 +36,10 @@ module Inspec
end
end
def dependencies
params[:depends] || []
end
def supports(sth, version = nil)
# Ignore supports with metadata.rb. This file is legacy and the way it
# it handles `supports` deprecated. A deprecation warning will be printed

View file

@ -8,6 +8,7 @@ require 'inspec/polyfill'
require 'inspec/fetcher'
require 'inspec/source_reader'
require 'inspec/metadata'
require 'inspec/dependencies'
module Inspec
class Profile # rubocop:disable Metrics/ClassLength
@ -206,6 +207,10 @@ module Inspec
true
end
def locked_dependencies
@locked_dependencies ||= load_dependencies
end
private
# Create an archive name for this profile and an additional options
@ -290,5 +295,12 @@ module Inspec
}
groups[file][:controls].push(id)
end
def load_dependencies
cwd = File.directory?(@target) ? @target : nil
res = Inspec::Dependencies.new(cwd, nil)
res.vendor(metadata.dependencies)
res
end
end
end

View file

@ -22,6 +22,8 @@ module Inspec
@backend = backend
@conf = conf.dup
@rules = {}
@dependencies = {}
@dependencies = conf['profile'].locked_dependencies unless conf['profile'].nil?
@require_loader = ::Inspec::RequireLoader.new
@attributes = []
reload_dsl
@ -30,7 +32,7 @@ module Inspec
def reload_dsl
resources_dsl = Inspec::Resource.create_dsl(@backend)
ctx = create_context(resources_dsl, rule_context(resources_dsl))
@profile_context = ctx.new(@backend, @conf, @require_loader)
@profile_context = ctx.new(@backend, @conf, @dependencies, @require_loader)
end
def load_libraries(libs)
@ -136,9 +138,10 @@ module Inspec
include Inspec::DSL
include resources_dsl
def initialize(backend, conf, require_loader) # rubocop:disable Lint/NestedMethodDefinition, Lint/DuplicateMethods
def initialize(backend, conf, dependencies, require_loader) # rubocop:disable Lint/NestedMethodDefinition, Lint/DuplicateMethods
@backend = backend
@conf = conf
@dependencies = dependencies
@require_loader = require_loader
@skip_profile = false
end