Add prototype of inspec.lock

This adds a basic prototype of inspec.lock. When the lockfile exists on
disk, the dependencies tree is constructed using the information in the
lock file rather than using the resolver.

Signed-off-by: Steven Danna <steve@chef.io>
This commit is contained in:
Steven Danna 2016-08-19 15:21:04 +01:00
parent 13e9a69701
commit 9c1b82e7d4
No known key found for this signature in database
GPG key ID: 94DFB46E861A7DAE
11 changed files with 309 additions and 72 deletions

View file

@ -10,7 +10,14 @@ module Fetchers
attr_reader :files attr_reader :files
def self.resolve(target) def self.resolve(target)
unless target.is_a?(String) && File.exist?(target) return nil unless target.is_a?(String)
# Support "urls" in the form of file://
if target.start_with?('file://')
target = target.gsub(%r{^file://}, '')
end
if !File.exist?(target)
nil nil
else else
new(target) new(target)
@ -18,6 +25,7 @@ module Fetchers
end end
def initialize(target) def initialize(target)
@target = target
if File.file?(target) if File.file?(target)
@files = [target] @files = [target]
else else

View file

@ -28,6 +28,14 @@ module Fetchers
end end
end end
def url
if parent
parent.url
else
'file://target'
end
end
def read(file) def read(file)
@contents[file] ||= read_from_tar(file) @contents[file] ||= read_from_tar(file)
end end

View file

@ -102,5 +102,9 @@ module Fetchers
@target = url @target = url
@archive = self.class.download_archive(url, opts) @archive = self.class.download_archive(url, opts)
end end
def url
@target
end
end end
end end

View file

@ -29,6 +29,14 @@ module Fetchers
end end
end end
def url
if parent
parent.url
else
'file://target'
end
end
def read(file) def read(file)
@contents[file] ||= read_from_zip(file) @contents[file] ||= read_from_zip(file)
end end

View file

@ -93,6 +93,13 @@ class Inspec::InspecCLI < Inspec::BaseCLI # rubocop:disable Metrics/ClassLength
exit 1 unless result[:summary][:valid] exit 1 unless result[:summary][:valid]
end end
desc 'vendor', 'Download all dependencies and generate a lockfile'
def vendor(path = nil)
profile = Inspec::Profile.for_target('./', opts)
lockfile = profile.generate_lockfile(path)
File.write('inspec.lock', lockfile.to_yaml)
end
desc 'archive PATH', 'archive a profile to tar.gz (default) or zip' desc 'archive PATH', 'archive a profile to tar.gz (default) or zip'
profile_options profile_options
option :output, aliases: :o, type: :string, option :output, aliases: :o, type: :string,

View file

@ -1,5 +1,6 @@
# encoding: utf-8 # encoding: utf-8
require 'inspec/dependencies/vendor_index' require 'inspec/dependencies/vendor_index'
require 'inspec/dependencies/requirement'
require 'inspec/dependencies/resolver' require 'inspec/dependencies/resolver'
module Inspec module Inspec
@ -10,17 +11,71 @@ module Inspec
# VendorIndex and the Resolver. # VendorIndex and the Resolver.
# #
class DependencySet class DependencySet
attr_reader :list, :vendor_path #
# Return a dependency set given a lockfile.
#
# @param lockfile [Inspec::Lockfile] A lockfile to generate the dependency set from
# @param cwd [String] Current working directory for relative path includes
# @param vendor_path [String] Path to the vendor directory
#
def self.from_lockfile(lockfile, cwd, vendor_path)
vendor_index = VendorIndex.new(vendor_path)
dep_tree = lockfile.deps.map do |dep|
Inspec::Requirement.from_lock_entry(dep, cwd, vendor_index)
end
# Flatten tree because that is all we know how to deal with for
# right now. Last dep seen for a given name wins right now.
dep_list = flatten_dep_tree(dep_tree)
new(cwd, vendor_path, dep_list)
end
def self.flatten_dep_tree(dep_tree)
dep_list = {}
dep_tree.each do |d|
dep_list[d.name] = d
dep_list.merge!(flatten_dep_tree(d.dependencies))
end
dep_list
end
attr_reader :vendor_path
attr_writer :dep_list
# initialize # initialize
# #
# @param cwd [String] current working directory for relative path includes # @param cwd [String] current working directory for relative path includes
# @param vendor_path [String] path which contains vendored dependencies # @param vendor_path [String] path which contains vendored dependencies
# @return [dependencies] this # @return [dependencies] this
def initialize(cwd, vendor_path) def initialize(cwd, vendor_path, dep_list = nil)
@cwd = cwd @cwd = cwd
@vendor_path = vendor_path || File.join(Dir.home, '.inspec', 'cache') @vendor_path = vendor_path
@list = nil @dep_list = dep_list
@dep_graph = nil
end
#
# Returns a flat list of all dependencies since that is all we
# know how to load at the moment.
#
def list
@dep_list ||= begin
return nil if @dep_graph.nil?
arr = @dep_graph.map(&:payload)
Hash[arr.map { |e| [e.name, e] }]
end
end
def to_array
return [] if @dep_graph.nil?
@dep_graph.map do |v|
# Resolver's list of dependency includes dependencies that
# we'll find further down the tree We don't want those at the
# top level as they should already be included in the to_hash
# output of the nodes that connect them.
if v.incoming_edges.empty?
v.payload.to_hash
end
end.compact
end end
# #
@ -29,10 +84,12 @@ module Inspec
# #
# @param dependencies [Gem::Dependency] list of dependencies # @param dependencies [Gem::Dependency] list of dependencies
# @return [nil] # @return [nil]
#
def vendor(dependencies) def vendor(dependencies)
return nil if dependencies.nil? || dependencies.empty? return nil if dependencies.nil? || dependencies.empty?
@vendor_index ||= VendorIndex.new(@vendor_path) @vendor_index ||= VendorIndex.new(@vendor_path)
@list = Resolver.resolve(dependencies, @vendor_index, @cwd) @dep_graph = Resolver.resolve(dependencies, @vendor_index, @cwd)
list
end end
end end
end end

View file

@ -0,0 +1,86 @@
# encoding: utf-8
require 'yaml'
module Inspec
class Lockfile
# When we finalize this feature, we should set these to 1
MINIMUM_SUPPORTED_VERSION = 0
CURRENT_LOCKFILE_VERSION = 0
def self.from_dependency_set(dep_set)
lockfile_content = {
'lockfile_version' => CURRENT_LOCKFILE_VERSION,
'depends' => dep_set.to_array,
}
new(lockfile_content)
end
def self.from_file(path)
parsed_content = YAML.load(File.read(path))
version = parsed_content['lockfile_version']
fail "No lockfile_version set in #{path}!" if version.nil?
validate_lockfile_version!(version.to_i)
new(parsed_content)
end
def self.validate_lockfile_version!(version)
if version < MINIMUM_SUPPORTED_VERSION
fail <<EOF
This lockfile specifies a lockfile_version of #{version} which is
lower than the minimum supported version #{MINIMUM_SUPPORTED_VERSION}.
Please create a new lockfile for this project by running:
inspec vendor
EOF
elsif version == 0
# Remove this case once this feature stablizes
$stderr.puts <<EOF
WARNING: This is a version 0 lockfile. Thank you for trying the
experimental dependency management feature. Please be aware you may
need to regenerate this lockfile in future versions as the feature is
currently in development.
EOF
elsif version > CURRENT_LOCKFILE_VERSION
fail <<EOF
This lockfile claims to be version #{version} which is greater than
the most recent lockfile version(#{CURRENT_LOCKFILE_VERSION}).
This may happen if you are using an older version of inspec than was
used to create the lockfile.
EOF
end
end
attr_reader :version, :deps
def initialize(lockfile_content_hash)
version = lockfile_content_hash['lockfile_version']
@version = version.to_i
parse_content_hash(lockfile_content_hash)
end
def to_yaml
{
'lockfile_version' => CURRENT_LOCKFILE_VERSION,
'depends' => @deps,
}.to_yaml
end
private
def parse_content_hash(lockfile_content_hash)
case version
when 0
parse_content_hash_0(lockfile_content_hash)
else
# If we've gotten here, there is likely a mistake in the
# lockfile version validation in the constructor.
fail "No lockfile parser for version #{version}"
end
end
def parse_content_hash_0(lockfile_content_hash)
@deps = lockfile_content_hash['depends']
end
end
end

View file

@ -1,19 +1,42 @@
# encoding: utf-8 # encoding: utf-8
require 'inspec/fetcher' require 'inspec/fetcher'
require 'digest'
module Inspec module Inspec
# #
# Inspec::Requirement represents a given profile dependency, where # Inspec::Requirement represents a given profile dependency, where
# appropriate we delegate to Inspec::Profile directly. # appropriate we delegate to Inspec::Profile directly.
# #
class Requirement class Requirement # rubocop:disable Metrics/ClassLength
attr_reader :name, :dep, :cwd, :opts attr_reader :name, :dep, :cwd, :opts
attr_writer :dependencies
def self.from_metadata(dep, vendor_index, 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, vendor_index, opts[:cwd], opts.merge(dep))
end
def self.from_lock_entry(entry, cwd, vendor_index)
req = new(entry['name'],
entry['version_constraints'],
vendor_index,
cwd, { url: entry['resolved_source'] })
locked_deps = []
Array(entry['dependencies']).each do |dep_entry|
locked_deps << Inspec::Requirement.from_lock_entry(dep_entry, cwd, vendor_index)
end
req.lock_deps(locked_deps)
req
end
def initialize(name, version_constraints, vendor_index, cwd, opts) def initialize(name, version_constraints, vendor_index, cwd, opts)
@name = name @name = name
@dep = Gem::Dependency.new(name, @version_requirement = Gem::Requirement.new(Array(version_constraints))
Gem::Requirement.new(Array(version_constraints)), @dep = Gem::Dependency.new(name, @version_requirement, :runtime)
:runtime)
@vendor_index = vendor_index @vendor_index = vendor_index
@opts = opts @opts = opts
@cwd = cwd @cwd = cwd
@ -24,64 +47,81 @@ module Inspec
@dep.match?(params[:name], params[:version]) @dep.match?(params[:name], params[:version])
end end
def to_hash
h = {
'name' => name,
'resolved_source' => source_url,
'version_constraints' => @version_requirement.to_s,
}
if !dependencies.empty?
h['dependencies'] = dependencies.map(&:to_hash)
end
if is_vendored?
h['content_hash'] = content_hash
end
h
end
def lock_deps(dep_array)
@dependencies = dep_array
end
def is_vendored?
@vendor_index.exists?(@name, source_url)
end
def content_hash
@content_hash ||= begin
archive_path = @vendor_index.archive_entry_for(@name, source_url)
fail "No vendored archive path for #{self}, cannot take content hash" if archive_path.nil?
Digest::SHA256.hexdigest File.read(archive_path)
end
end
def source_url def source_url
case source_type if opts[:path]
when :local_path "file://#{opts[:path]}"
"file://#{File.expand_path(opts[:path])}" elsif opts[:url]
when :url opts[:url]
@opts[:url]
end end
end end
def local_path def local_path
@local_path ||= case source_type @local_path ||= if fetcher.class == Fetchers::Local
when :local_path File.expand_path(fetcher.target, @cwd)
File.expand_path(opts[:path], @cwd)
else else
@vendor_index.prefered_entry_for(@name, source_url) @vendor_index.prefered_entry_for(@name, source_url)
end end
end end
def source_type def fetcher
@source_type ||= if @opts[:path] @fetcher ||= Inspec::Fetcher.resolve(source_url)
:local_path fail "No fetcher for #{name} (options: #{opts})" if @fetcher.nil?
elsif opts[:url] @fetcher
:url
else
fail "Cannot determine source type from #{opts}"
end
end
def fetcher_class
@fetcher_class ||= case source_type
when :local_path
Fetchers::Local
when :url
Fetchers::Url
else
fail "No known fetcher for dependency #{name} with source_type #{source_type}"
end
fail "No fetcher for #{name} (options: #{opts})" if @fetcher_class.nil?
@fetcher_class
end end
def pull def pull
case source_type # TODO(ssd): Dispatch on the class here is gross. Seems like
when :local_path # Fetcher is missing an API we want.
if fetcher.class == Fetchers::Local || @vendor_index.exists?(@name, source_url)
local_path local_path
else else
if @vendor_index.exists?(@name, source_url) @vendor_index.add(@name, source_url, fetcher.archive.path)
local_path end
else end
archive = fetcher_class.download_archive(source_url)
@vendor_index.add(@name, source_url, archive.path) def dependencies
end return @dependencies unless @dependencies.nil?
@dependencies = profile.metadata.dependencies.map do |r|
Inspec::Requirement.from_metadata(r, @vendor_index, cwd: @cwd)
end end
end end
def to_s def to_s
dep.to_s "#{dep} (#{source_url})"
end end
def path def path
@ -92,12 +132,5 @@ module Inspec
return nil if path.nil? return nil if path.nil?
@profile ||= Inspec::Profile.for_target(path, {}) @profile ||= Inspec::Profile.for_target(path, {})
end end
def self.from_metadata(dep, vendor_index, 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, vendor_index, opts[:cwd], opts.merge(dep))
end
end end
end end

View file

@ -18,7 +18,7 @@ module Inspec
req || fail("Cannot initialize dependency: #{req}") req || fail("Cannot initialize dependency: #{req}")
end end
new(vendor_index, opts.merge(cwd: cwd)).resolve(reqs) new(vendor_index, opts).resolve(reqs)
end end
def initialize(vendor_index, opts = {}) def initialize(vendor_index, opts = {})
@ -26,7 +26,6 @@ module Inspec
@debug_mode = false @debug_mode = false
@vendor_index = vendor_index @vendor_index = vendor_index
@cwd = opts[:cwd] || './'
@resolver = Molinillo::Resolver.new(self, self) @resolver = Molinillo::Resolver.new(self, self)
@search_cache = {} @search_cache = {}
end end
@ -39,8 +38,6 @@ module Inspec
requirements.each(&:pull) requirements.each(&:pull)
@base_dep_graph = Molinillo::DependencyGraph.new @base_dep_graph = Molinillo::DependencyGraph.new
@dep_graph = @resolver.resolve(requirements, @base_dep_graph) @dep_graph = @resolver.resolve(requirements, @base_dep_graph)
arr = @dep_graph.map(&:payload)
Hash[arr.map { |e| [e.name, e] }]
rescue Molinillo::VersionConflict => e rescue Molinillo::VersionConflict => e
raise VersionConflict.new(e.conflicts.keys.uniq, e.message) raise VersionConflict.new(e.conflicts.keys.uniq, e.message)
rescue Molinillo::CircularDependencyError => e rescue Molinillo::CircularDependencyError => e
@ -91,9 +88,7 @@ module Inspec
# @return [Array<Object>] the dependencies that are required by the given # @return [Array<Object>] the dependencies that are required by the given
# `specification`. # `specification`.
def dependencies_for(specification) def dependencies_for(specification)
specification.profile.metadata.dependencies.map do |r| specification.dependencies
Inspec::Requirement.from_metadata(r, @vendor_index, cwd: @cwd)
end
end end
# Determines whether the given `requirement` is satisfied by the given # Determines whether the given `requirement` is satisfied by the given

View file

@ -18,9 +18,9 @@ module Inspec
# #
class VendorIndex class VendorIndex
attr_reader :path attr_reader :path
def initialize(path) def initialize(path = nil)
@path = path @path = path || File.join(Dir.home, '.inspec', 'cache')
FileUtils.mkdir_p(path) unless File.directory?(path) FileUtils.mkdir_p(@path) unless File.directory?(@path)
end end
def add(name, source, path_from) def add(name, source, path_from)
@ -42,7 +42,14 @@ module Inspec
path = base_path_for(name, source_url) path = base_path_for(name, source_url)
if File.directory?(path) if File.directory?(path)
path path
elsif File.exist?("#{path}.tar.gz") else
archive_entry_for(name, source_url)
end
end
def archive_entry_for(name, source_url)
path = base_path_for(name, source_url)
if File.exist?("#{path}.tar.gz")
"#{path}.tar.gz" "#{path}.tar.gz"
elsif File.exist?("#{path}.zip") elsif File.exist?("#{path}.zip")
"#{path}.zip" "#{path}.zip"

View file

@ -8,6 +8,7 @@ require 'inspec/polyfill'
require 'inspec/fetcher' require 'inspec/fetcher'
require 'inspec/source_reader' require 'inspec/source_reader'
require 'inspec/metadata' require 'inspec/metadata'
require 'inspec/dependencies/lockfile'
require 'inspec/dependencies/dependency_set' require 'inspec/dependencies/dependency_set'
module Inspec module Inspec
@ -211,6 +212,32 @@ module Inspec
@locked_dependencies ||= load_dependencies @locked_dependencies ||= load_dependencies
end end
def lockfile_exists?
File.exist?(lockfile_path)
end
def lockfile_path
File.join(cwd, 'inspec.lock')
end
def cwd
@target.is_a?(String) && File.directory?(@target) ? @target : nil
end
def lockfile
@lockfile ||= if lockfile_exists?
Inspec::Lockfile.from_file(lockfile_path)
else
generate_lockfile
end
end
def generate_lockfile(vendor_path = nil)
res = Inspec::DependencySet.new(cwd, vendor_path)
res.vendor(metadata.dependencies)
Inspec::Lockfile.from_dependency_set(res)
end
private private
# Create an archive name for this profile and an additional options # Create an archive name for this profile and an additional options
@ -225,7 +252,7 @@ module Inspec
name = params[:name] || name = params[:name] ||
fail('Cannot create an archive without a profile name! Please '\ fail('Cannot create an archive without a profile name! Please '\
'specify the name in metadata or use --output to create the archive.') 'specify the name in metadata or use --output to create the archive.')
ext = opts[:zip] ? 'zip' : 'tar.gz' ext = opts[:zip] ? 'zip' : 'tar.gz'
slug = name.downcase.strip.tr(' ', '-').gsub(/[^\w-]/, '_') slug = name.downcase.strip.tr(' ', '-').gsub(/[^\w-]/, '_')
Pathname.new(Dir.pwd).join("#{slug}.#{ext}") Pathname.new(Dir.pwd).join("#{slug}.#{ext}")
@ -297,10 +324,7 @@ module Inspec
end end
def load_dependencies def load_dependencies
cwd = @target.is_a?(String) && File.directory?(@target) ? @target : nil Inspec::DependencySet.from_lockfile(lockfile, cwd, nil)
res = Inspec::DependencySet.new(cwd, nil)
res.vendor(metadata.dependencies)
res
end end
end end
end end