CFINSPEC-13: Adds the ability to install and load the gem depedencies specified in the metadata file of profile/resource pack.

Signed-off-by: Vasu1105 <vasundhara.jagdale@chef.io>
This commit is contained in:
Vasu1105 2022-02-16 21:49:01 +05:30 committed by Clinton Wolfe
parent c5ac267ab4
commit e3ef2cb912
15 changed files with 349 additions and 0 deletions

View file

@ -28,3 +28,6 @@ require "inspec/base_cli"
require "inspec/fetcher"
require "inspec/source_reader"
require "inspec/resource"
require "inspec/dependency_loader"
require "inspec/dependency_installer"

View file

@ -201,6 +201,8 @@ module Inspec
long_desc: "Maximum seconds to allow commands to run during execution. A timed out command is considered an error."
option :reporter_include_source, type: :boolean, default: false,
desc: "Include full source code of controls in the CLI report"
option :auto_install_gems, type: :boolean, default: false,
desc: "Auto install gem dependencies of profile/resource pack defined in metadata file."
end
def self.help(*args)

View file

@ -0,0 +1,69 @@
# This class will install the gem depedencies for profiles etc.
# The basic things which is required to install dependencies is the gem path where gem needs to be installed
# and the list of gems needs to be installed.
require "rubygems/remote_fetcher"
require "forwardable" unless defined?(Forwardable)
module Inspec
class DependencyInstaller
extend Forwardable
attr_reader :gem_path, :requested_gems, :dependency_loader
def_delegator :dependency_loader, :inspec_gem_path
def initialize(gem_path = nil, requested_gems = [])
@dependency_loader = Inspec::DependencyLoader.new
@gem_path = gem_path || inspec_gem_path
@requested_gems = requested_gems
end
def install
requested_gems.each do |requested_gem|
version = requested_gem[:version].nil? ? ">= 0" : requested_gem[:version]
install_from_remote_gems(requested_gem[:name], { version: version })
end
end
private
def install_from_remote_gems(requested_gem_name, opts)
gem_dependency = Gem::Dependency.new(requested_gem_name, opts[:version] || "> 0")
# BestSet is rubygems.org API + indexing, APISet is for custom sources
sources = if opts[:source]
Gem::Resolver::APISet.new(URI.join(opts[:source] + "/api/v1/dependencies"))
else
Gem::Resolver::BestSet.new
end
begin
install_gem_to_gems_dir(gem_dependency, [sources], opts[:update_mode])
rescue Gem::RemoteFetcher::FetchError => gem_ex
ex = Inspec::GemDependencyInstallError.new(gem_ex.message)
ex.gem_name = requested_gem_name
raise ex
end
end
def install_gem_to_gems_dir(gem_dependency, extra_request_sets = [], update_mode = false)
# Solve the dependency (that is, find a way to install the new gem and anything it needs)
request_set = Gem::RequestSet.new(gem_dependency)
begin
solution = request_set.resolve
rescue Gem::UnsatisfiableDependencyError => gem_ex
ex = Inspec::GemDependencyInstallError.new(gem_ex.message)
ex.gem_name = gem_dependency.name
raise ex
end
# OK, perform the installation.
# Ignore deps here, because any needed deps should already be baked into gem_dependency
request_set.install_into(gem_path, true, ignore_dependencies: true, document: [])
# Locate the GemVersion for the new dependency and return it
solution.detect { |g| g.name == gem_dependency.name }.version
end
end
end

View file

@ -0,0 +1,90 @@
# This class will load the gem depedencies for profiles etc.
# The basic things which is required to load gem dependencies is the path from which gems needs to be loaded
# and the list of gems needs to be loaded.
module Inspec
class DependencyLoader
attr_accessor :gem_path, :gem_list
# initializes the dependency_loader
def initialize(gem_path = nil, gem_list = [])
@gem_path = gem_path || inspec_gem_path
@gem_list = gem_list
end
def load
Gem.path << gem_path
Gem.refresh
gem_list.each do |gem_data|
version = gem_data[:version].nil? ? ">= 0" : gem_data[:version]
activate_gem_dependency(gem_data[:name], version)
end
end
def inspec_gem_path
self.class.inspec_gem_path
end
def self.inspec_gem_path
require "rbconfig" unless defined?(RbConfig)
ruby_abi_version = RbConfig::CONFIG["ruby_version"]
# TODO: why are we installing under the api directory for plugins?
base_dir = Inspec.config_dir
base_dir = File.realpath base_dir if File.exist? base_dir
File.join(base_dir, "gems", ruby_abi_version)
end
# Lists all gems found in the inspec_gem_path.
# @return [Array[Gem::Specification]] Specs of all gems found.
def list_managed_gems
Dir.glob(File.join(gem_path, "specifications", "*.gemspec")).map { |p| Gem::Specification.load(p) }
end
def list_installed_gems
list_managed_gems
end
def gem_installed?(name)
list_installed_gems.detect { |spec| spec.name == name }
end
def gem_version_installed?(name, version)
if version.nil?
gem_installed?(name)
else
list_installed_gems.detect { |spec| spec.name == name && spec.version == Gem::Version.new(version) }
end
end
private
def activate_gem_dependency(name, version_constraint = "> 0")
gem_deps = [Gem::Dependency.new(name.to_s, version_constraint)]
managed_gem_set = Gem::Resolver::VendorSet.new
# TODO: Next two lines merge our managed gems with the other gems available
# in our "local universe" - which may be the system, or it could be in a Bundler microcosm,
# or rbenv, etc. Do we want to merge that, though?
distrib_gem_set = Gem::Resolver::CurrentSet.new
installed_gem_set = Gem::Resolver.compose_sets(managed_gem_set, distrib_gem_set)
# So, given what we need, and what we have available, what activations are needed?
resolver = Gem::Resolver.new(gem_deps, installed_gem_set)
begin
solution = resolver.resolve
rescue Gem::UnsatisfiableDependencyError => gem_ex
# If you broke your install, or downgraded to a plugin with a bad gemspec, you could get here.
ex = Inspec::GemDependencyLoadError.new(gem_ex.message)
raise ex
end
solution.each do |activation_request|
next if activation_request.full_spec.activated?
activation_request.full_spec.activate
# TODO: If we are under Bundler, inform it that we loaded a gem
end
end
end
end

View file

@ -15,4 +15,11 @@ module Inspec
class ConfigError::Invalid < ConfigError; end
class UserInteractionRequired < Error; end
class GemDependencyLoadError < Error; end
class GemDependencyInstallError < Error
attr_accessor :gem_name
attr_accessor :version
end
end

View file

@ -51,6 +51,10 @@ module Inspec
params[:depends] || []
end
def gem_dependencies
params[:required_gems] || []
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

@ -13,6 +13,8 @@ require "inspec/dependencies/cache"
require "inspec/dependencies/lockfile"
require "inspec/dependencies/dependency_set"
require "inspec/utils/json_profile_summary"
require "inspec/dependency_loader"
require "inspec/dependency_installer"
module Inspec
class Profile
@ -378,6 +380,53 @@ module Inspec
@runner_context
end
# Loads the required gems specified in the Profile's metadata file from default inspec gems path i.e. ~/.inspec/gems
# else installs and loads them.
def load_gem_dependencies
gem_dependencies = metadata. gem_dependencies
gem_dependencies.each do |gem_data|
dependency_loader = DependencyLoader.new
gem_version = gem_data[:version].split[1] unless gem_data[:version].nil?
if dependency_loader.gem_version_installed?(gem_data[:name], gem_version) || dependency_loader.gem_installed?(gem_data[:name])
load_gem_dependency(gem_data)
else
if Inspec::Config.cached[:auto_install_gems]
install_gem_dependency(gem_data)
load_gem_dependency(gem_data)
else
ui = Inspec::UI.new
gem_dependencies.each { |gem_dependency| ui.list_item("#{gem_dependency[:name]} #{gem_dependency[:version]}") }
choice = ui.prompt.select("Would you like to install profile gem dependencies listed above?", %w{Yes No})
if choice == "Yes"
Inspec::Config.cached[:auto_install_gems] = true
load_gem_dependencies
else
ui.error "Unable to resolve above listed profile gem dependencies."
Inspec::UI.new.exit(:gem_dependency_load_error)
end
end
end
end
end
# Requires gem_data as argument.
# gem_dta example: { name: "gem_name", version: "0.0.1"}
def load_gem_dependency(gem_data)
dependency_loader = DependencyLoader.new(nil, [gem_data])
dependency_loader.load
rescue Inspec::GemDependencyLoadError => e
raise e
end
# Requires gem_data as argument.
# gem_dta example: { name: "gem_name", version: "0.0.1"}
def install_gem_dependency(gem_data)
gem_dependency = DependencyInstaller.new(nil, [gem_data])
gem_dependency.install
rescue Inspec::GemDependencyInstallError => e
raise e
end
def to_s
"Inspec::Profile<#{name}>"
end

View file

@ -105,6 +105,7 @@ module Inspec
write_lockfile(profile) if @create_lockfile
profile.locked_dependencies
profile.load_gem_dependencies unless profile.metadata.gem_dependencies.empty?
profile_context = profile.load_libraries
profile_context.dependencies.list.values.each do |requirement|

View file

@ -30,6 +30,7 @@ module Inspec
EXIT_USAGE_ERROR = 1
EXIT_PLUGIN_ERROR = 2
EXIT_FATAL_DEPRECATION = 3
EXIT_GEM_DEPENDENCY_LOAD_ERROR = 4
EXIT_LICENSE_NOT_ACCEPTED = 172
EXIT_FAILED_TESTS = 100
EXIT_SKIPPED_TESTS = 101

View file

@ -0,0 +1,3 @@
# Example InSpec Profile
This example shows the implementation of an InSpec profile.

View file

@ -0,0 +1,11 @@
require "money"
control "tmp-1.0" do
Money.rounding_mode = BigDecimal::ROUND_HALF_UP
m = Money.from_cents(1000, "USD")
cents = m.cents
describe cents do
it { should eq 1000 }
end
end

View file

@ -0,0 +1,14 @@
name: profile-with-gem-dependency
title: InSpec Profile
maintainer: The Authors
copyright: The Authors
copyright_email: you@example.com
license: Apache-2.0
summary: An InSpec Compliance Profile
version: 0.1.0
supports:
platform: os
required_gems:
- name: money
version: ">= 6.15.0"
- name: monetize

View file

@ -0,0 +1,34 @@
require "functional/helper"
describe "profile with gem dependencies" do
include FunctionalHelper
let(:gem_dependency_profiles_path) { File.join(profile_path, "profile-with-gem-dependency") }
let(:config_dir_path) { File.expand_path "test/fixtures/config_dirs" }
def reset_globals
ENV["HOME"] = Dir.home
end
before(:each) do
reset_globals
ENV["HOME"] = File.join(config_dir_path, "profile_gems")
end
after do
reset_globals
if config_dir_path
Dir.glob(File.join(config_dir_path, "profile_gems")).each do |path|
next if path.end_with? ".gitkeep"
FileUtils.rm_rf(path)
end
end
end
it "installs the gem dependencies and load them if --auto-install-gems is provided." do
out = inspec_with_env("exec #{gem_dependency_profiles_path} --no-create-lockfile --auto-install-gems")
_(out.stderr).must_equal ""
assert_exit_code 0, out
end
end

View file

@ -0,0 +1,61 @@
require "helper"
require "inspec/dependency_loader"
require "byebug"
describe "dependency_loader" do
let(:config_dir_path) { File.expand_path "test/fixtures/config_dirs" }
let(:gem_list) { [{ name: "inspec-test-fixture", version: "0.1.0" }] }
def reset_globals
ENV["HOME"] = Dir.home
end
before(:each) do
reset_globals
ENV["HOME"] = File.join(config_dir_path, "test-fixture-1-float/gems/2.7.0")
end
after(:each) do
reset_globals
end
let(:gem_path) { [ENV["HOME"]] }
let(:dependency_loader) { Inspec::DependencyLoader.new(gem_path, gem_list) }
describe "load" do
it "loads the gem dependency if already installed on the given gem path." do
result = dependency_loader.load
_(result).must_equal gem_list
end
it "raises error if the gem dependency not exist on the given gem path." do
dependency_loader.gem_list = [{ name: "test_gem", version: "0.0.1" }]
err = _ { dependency_loader.load }.must_raise Inspec::GemDependencyLoadError
_(err.message).must_equal "Unable to resolve dependency: user requested \'test_gem (= 0.0.1)\'"
end
end
describe "gem_installed?" do
it "returns the list of gems installed if gem already installed" do
result = dependency_loader.gem_installed?("inspec-test-fixture")
_(result).wont_be_nil
end
it "returns nil if specified gem is not already installed." do
result = dependency_loader.gem_installed?("test_gem")
_(result).must_be_nil
end
end
describe "gem_version_installed?" do
it "returns the list of gems installed if gem with specified version is already installed" do
result = dependency_loader.gem_version_installed?("inspec-test-fixture", "0.1.0")
_(result).wont_be_nil
end
it "returns nil if gem with specified version is not already installed." do
result = dependency_loader.gem_version_installed?("test_gem", "0.0.1")
_(result).must_be_nil
end
end
end