Plugins API v2: Loader, Base API, and Test Harness (#3278)

* Functional tests for userdir option
* Accepts --config-dir CLI option
* Actually loads a config file from the config dir, more cases to test
* Able to load config and verify contents from config-dir
* Functional tests to ensure precedence for config options
* Enable setting config dir via env var
* .inspec, not .inspec.d
* Begin converting PluginCtl to PluginLoader/Registry
* Able to load and partially validate the plugins.json file
* More work on the plugin loader
* Break the world, move next gen stuff to plugin/
* Be sure to require base cli in bundled plugins
* Move test file
* Revert changes to v1 plugin, so we can have a separate one
* Checkpoint commit
* Move v2 plugin work to v2 area
* Move plugins v1 code into an isolated directory
* rubocop fixes
* Rip out the stuff about a user-dir config file, just use a plugin file
* Two psuedocode test file
* Working base API, moock plugin type, and loader.
* Adjust load path to be more welcoming
* Silence circular depencency warning, which was breaking a unit test
* Linting
* Fix plugin type registry, add tests to cover
* Feedback from Jerry

Signed-off-by: Clinton Wolfe <clintoncwolfe@gmail.com>
This commit is contained in:
Clinton Wolfe 2018-08-16 18:16:32 -04:00 committed by Jared Quick
parent 1bd6ab08e5
commit 811318f2f8
50 changed files with 1158 additions and 36 deletions

161
docs/dev/plugins.md Normal file
View file

@ -0,0 +1,161 @@
# Developing InSpec Plugins for the v2 plugin API
## Introduction
### Inspiration
The software design of the InSpec Plugin v2 API is deeply inspired by the Vagrant plugin v2 system. While the InSpec Plugin v2 system is an independent implementation, acknowledgements are due to the Hashicorp team for such a well-thought-out design.
### Note About versions
"v2" refers to the second major version of the Plugin API. It doesn't refer to the InSpec release number.
### Design Goals
* Load-on-demand. Improve `inspec` startup time by making plugins load heavy libraries only if they are being used.
* Independent velocity. Enable passionate community members to contribute at their own pace by shifting core development into plugin development
* Increase dogfooding. Convert internal components into plugins to reduce core complexity and allow testing in isolation
### Design Anti-goals
* Don't implement resources in plugins; use resource packs for that.
## How Plugins are Located and Loaded
### Plugins are usually gems
The normal distribution and installation method is via gems, handled by the `inspec plugin` command.
TODO: give basic overview of `inspec plugin` and link to docs
### Plugins may also be found by path
For local development or site-specific installations, you can also 'install' a plugin by path using `inspec plugin`, or edit `~/.inspec/plugins.json` directly to add a plugin.
### The plugins.json file
InSpec stores its list of known plugins in a file, `~/.inspec/plugins.json`. The purpose of this file is avoid having to do a gem path filesystem scan to locate plugins. When you install, update, or uninstall a plugin using `inspec plugin`, InSpec updates this file.
You can tell inspec to use a different config directory using the INSPEC_CONFIG_DIR environment variable.
Top-level entries in the JSON file:
* `plugins_config_version` - must have the value "1.0.0". Reserved for future format changes.
* `plugins` - an Array of Hashes, each containing information about plugins that are expected to be installed
Each plugin entry may have the following keys:
* `name` - Required. String name of the plugin. Internal machine name of the plugin. Must match `plugin_name` DSL call (see Plugin class below).
* `installation_type` - Optional, default "gem". Selects a loading mechanism, may be either "path" or "gem"
* `installation_path` - Required if installation_type is "path". A `require` will be attempted against this path. It may be absolute or relative; InSpec adds both the process current working directory as well as the InSpec installation root to the load path.
TODO: keys for gem installations
Putting this all together, here is a plugins.json file from the InSpec test suite:
```json
{
"plugins_config_version" : "1.0.0",
"plugins": [
{
"name": "inspec-meaning-of-life",
"installation_type": "path",
"installation_path": "test/unit/mock/plugins/meaning_of_life_path_mode/inspec-meaning-of-life"
}
]
}
```
## Plugin Parts
### A Typical Plugin File Layout
```
inspec-my-plugin.gemspec
lib/
inspec-my-plugin.rb # Entry point
inspec-my-plugin/
cli.rb # An implementation file
plugin.rb # Plugin definition file
heavyweight.rb # A support file
```
Generally, except for the entry point, you may name these files anything you like; however, the above example is the typical convention.
### Gemspec and Plugin Dependencies
This is a normal Gem specification file. When you release your plugin as a gem, you can declare dependencies here, and InSpec will automatically install them along with your plugin.
If you are using a path-based install, InSpec will not manage your dependencies.
### Entry Point
The entry point is the file that will be `require`d at load time (*not* activation time; see Plugin Lifecycle, below). You should load the bare minimum here - only the plugin definition file. Do not load any plugin dependencies in this file.
```ruby
# lib/inspec-my-plugin.rb
require_relative 'inspec-my-plugin/plugin'
```
### Plugin Definition File
The plugin definition file uses the plugin DSL to declare a small amount of metadata, followed by as many activation hooks as your plugin needs.
While you may use any valid Ruby module name, we encourage you to namespace your plugin under `InspecPlugins::YOUR_PLUGIN`.
```ruby
# lib/inspec-my-plugin/plugin.rb
module InspecPlugins
module MyPlugin
# Class name doesn't matter, but this is a reasonable default name
class PluginDefinition < Inspec.plugin(2)
# Metadata
# Must match entry in plugins.json
plugin_name :'inspec-my-plugin'
# Activation hooks
# TODO
end
end
end
```
### Implementation Files
TODO
## Plugin Lifecycle
All queries regarding plugin state should be directed to Inspec::Plugin::V2::Registry.instance, a singleton object.
```ruby
registry = Inspec::Plugin::V2::Registry.instance
plugin_status = registry[:'inspec-meaning-of-life']
```
### Discovery (Known Plugins)
If a plugin is mentioned in `plugins.json`, it is *known*. You can get its status, a `Inspec::Plugin::V2::Status` object.
Reading the plugins.json file is handled by the Loader when Loader.new is called; at that point the registry should know about plugins.
### Loading
Next, we load plugins. Loading means that we `require` the entry point determined from the plugins.json. Your plugin definition file will thus execute.
If things go right, the Status now has a bunch of Activators, each with a block that has not yet executed.
If things go wrong, have a look at `status.load_exception`.
### Activation
Depending on the plugin type, activation may be triggered by a number of different events.
TODO
### Execution
Depending on the plugin type, execution may be triggered by a number of different events.
TODO

View file

@ -6,6 +6,7 @@ require 'pathname'
require 'set'
require 'tempfile'
require 'yaml'
require 'inspec/base_cli'
# Notes:
#

View file

@ -4,6 +4,7 @@
require 'thor'
require 'erb'
require 'inspec/base_cli'
module Compliance
class ComplianceCLI < Inspec::BaseCLI

View file

@ -2,6 +2,7 @@
# author: Adam Leff
require 'thor'
require 'inspec/base_cli'
module Habitat
class HabitatProfileCLI < Thor

View file

@ -2,6 +2,7 @@
require 'pathname'
require_relative 'renderer'
require 'inspec/base_cli'
module Init
class CLI < Inspec::BaseCLI

View file

@ -1,6 +1,7 @@
# encoding: utf-8
# author: Christoph Hartmann
# author: Dominik Richter
require 'inspec/base_cli'
module Supermarket
class SupermarketCLI < Inspec::BaseCLI

View file

@ -16,9 +16,11 @@ require 'inspec/shell'
require 'inspec/formatters'
require 'inspec/reporters'
# all utils that may be required by plugins
require 'inspec/plugin/v2'
require 'inspec/plugin/v1'
# all utils that may be required by legacy plugins
require 'inspec/base_cli'
require 'inspec/fetcher'
require 'inspec/source_reader'
require 'inspec/resource'
require 'inspec/plugins'

View file

@ -10,7 +10,8 @@ require 'pp'
require 'utils/json_log'
require 'utils/latest_version'
require 'inspec/base_cli'
require 'inspec/plugins'
require 'inspec/plugin/v1'
require 'inspec/plugin/v2'
require 'inspec/runner_mock'
require 'inspec/env_printer'
require 'inspec/schema'
@ -20,7 +21,7 @@ class Inspec::InspecCLI < Inspec::BaseCLI
desc: 'Set the log level: info (default), debug, warn, error'
class_option :log_location, type: :string,
desc: 'Location to send diagnostic log messages to. (default: STDOUT or STDERR)'
desc: 'Location to send diagnostic log messages to. (default: STDOUT or Inspec::Log.error)'
class_option :diagnose, type: :boolean,
desc: 'Show diagnostics (versions, configurations)'
@ -282,17 +283,34 @@ class Inspec::InspecCLI < Inspec::BaseCLI
end
end
# Load all plugins on startup
ctl = Inspec::PluginCtl.new
ctl.list.each { |x| ctl.load(x) }
begin
# Load v2 plugins
v2_loader = Inspec::Plugin::V2::Loader.new
v2_loader.load_all
v2_loader.exit_on_load_error
# load CLI plugins before the Inspec CLI has been started
Inspec::Plugins::CLI.subcommands.each { |_subcommand, params|
Inspec::InspecCLI.register(
params[:klass],
params[:subcommand_name],
params[:usage],
params[:description],
params[:options],
)
}
# Load v1 plugins on startup
ctl = Inspec::PluginCtl.new
ctl.list.each { |x| ctl.load(x) }
# load v1 CLI plugins before the Inspec CLI has been started
Inspec::Plugins::CLI.subcommands.each { |_subcommand, params|
Inspec::InspecCLI.register(
params[:klass],
params[:subcommand_name],
params[:usage],
params[:description],
params[:options],
)
}
rescue Inspec::Plugin::V2::Exception => v2ex
Inspec::Log.error v2ex.message
if ARGV.include?('--debug')
Inspec::Log.error v2ex.class.name
Inspec::Log.error v2ex.backtrace.join("\n")
else
Inspec::Log.error 'Run again with --debug for a stacktrace.'
end
exit 2
end

View file

@ -1,6 +1,5 @@
# encoding: utf-8
require 'inspec/cached_fetcher'
require 'inspec/dependencies/dependency_set'
require 'semverse'
module Inspec

View file

@ -2,8 +2,7 @@
# author: Dominik Richter
# author: Christoph Hartmann
require 'inspec/plugins'
require 'utils/plugin_registry'
require 'inspec/plugin/v1'
module Inspec
class FetcherRegistry < PluginRegistry

View file

@ -1,7 +1,7 @@
# encoding: utf-8
# author: Steven Danna
# author: Victoria Jeffrey
require 'inspec/plugins/resource'
require 'inspec/plugin/v1/plugin_types/resource'
require 'inspec/dsl_shared'
module Inspec

2
lib/inspec/plugin/v1.rb Normal file
View file

@ -0,0 +1,2 @@
require 'inspec/plugin/v1/plugins'
require 'inspec/plugin/v1/registry'

View file

@ -2,6 +2,8 @@
# author: Christoph Hartmann
# author: Dominik Richter
require 'inspec/plugin/v1/registry'
module Inspec
module Plugins
# stores all CLI plugin, we expect those to the `Thor` subclasses

View file

@ -1,8 +1,8 @@
# encoding: utf-8
# author: Dominik Richter
# author: Christoph Hartmann
require 'utils/plugin_registry'
require 'inspec/file_provider'
require 'inspec/plugin/v1/registry'
module Inspec
module Plugins

View file

@ -2,7 +2,7 @@
# author: Dominik Richter
# author: Christoph Hartmann
require 'utils/plugin_registry'
require 'inspec/plugin/v1/registry'
module Inspec
module Plugins

View file

@ -2,7 +2,7 @@
# author: Dominik Richter
# author: Christoph Hartmann
require 'utils/plugin_registry'
require 'inspec/plugin/v1/registry'
module Inspec
module Plugins

View file

@ -6,12 +6,14 @@ require 'forwardable'
module Inspec
# Resource Plugins
# NOTE: the autoloading here is rendered moot by the fact that
# all core plugins are `require`'d by the base inspec.rb
module Plugins
autoload :Resource, 'inspec/plugins/resource'
autoload :CLI, 'inspec/plugins/cli'
autoload :Fetcher, 'inspec/plugins/fetcher'
autoload :SourceReader, 'inspec/plugins/source_reader'
autoload :Secret, 'inspec/plugins/secret'
autoload :Resource, 'inspec/plugin/v1/plugin_types/resource'
autoload :CLI, 'inspec/plugin/v1/plugin_types/cli'
autoload :Fetcher, 'inspec/plugin/v1/plugin_types/fetcher'
autoload :SourceReader, 'inspec/plugin/v1/plugin_types/source_reader'
autoload :Secret, 'inspec/plugin/v1/plugin_types/secret'
end
# PLEASE NOTE: The Plugin system is an internal mechanism for connecting

30
lib/inspec/plugin/v2.rb Normal file
View file

@ -0,0 +1,30 @@
require 'inspec/errors'
module Inspec
module Plugin
module V2
class Exception < Inspec::Error; end
class ConfigError < Inspec::Plugin::V2::Exception; end
class LoadError < Inspec::Plugin::V2::Exception; end
end
end
end
require_relative 'v2/registry'
require_relative 'v2/loader'
require_relative 'v2/plugin_base'
# Load all plugin type base classes
Dir.glob(File.join(__dir__, 'v2', 'plugin_types', '*.rb')).each { |file| require file }
module Inspec
# Provides the base class that plugin implementors should use.
def self.plugin(version, plugin_type = nil)
unless version == 2
raise 'Only plugins version 2 is supported!'
end
return Inspec::Plugin::V2::PluginBase if plugin_type.nil?
Inspec::Plugin::V2::PluginBase.base_class_for_type(plugin_type)
end
end

View file

@ -0,0 +1,16 @@
module Inspec::Plugin::V2
Activator = Struct.new(
:plugin_name,
:plugin_type,
:activator_name,
:activated,
:exception,
:activation_proc,
:implementation_class,
) do
def initialize(*)
super
self[:activated] = false
end
end
end

View file

@ -0,0 +1,191 @@
require 'json'
require 'inspec/log'
# Add the current directory of the process to the load path
$LOAD_PATH.unshift('.') unless $LOAD_PATH.include?('.')
# Add the InSpec source root directory to the load path
folder = File.expand_path(File.join('..', '..', '..', '..'), __dir__)
$LOAD_PATH.unshift(folder) unless $LOAD_PATH.include?('folder')
module Inspec::Plugin::V2
class Loader
attr_reader :registry, :options
def initialize(options = {})
@options = options
@registry = Inspec::Plugin::V2::Registry.instance
determine_plugin_conf_file
read_conf_file
unpack_conf_file
detect_bundled_plugins unless options[:omit_bundles]
end
def load_all
registry.each do |plugin_name, plugin_details|
# We want to capture literally any possible exception here, since we are storing them.
# rubocop: disable Lint/RescueException
begin
# We could use require, but under testing, we need to repeatedly reload the same
# plugin.
if plugin_details.entry_point.include?('test/unit/mock/plugins')
load plugin_details.entry_point + '.rb'
else
require plugin_details.entry_point
end
plugin_details.loaded = true
annotate_status_after_loading(plugin_name)
rescue ::Exception => ex
plugin_details.load_exception = ex
Inspec::Log.error "Could not load plugin #{plugin_name}"
end
# rubocop: enable Lint/RescueException
end
end
# This should possibly be in either lib/inspec/cli.rb or Registry
def exit_on_load_error
if registry.any_load_failures?
Inspec::Log.error 'Errors were encountered while loading plugins...'
registry.plugin_statuses.select(&:load_exception).each do |plugin_status|
Inspec::Log.error 'Plugin name: ' + plugin_status.name.to_s
Inspec::Log.error 'Error: ' + plugin_status.load_exception.message
if ARGV.include?('--debug')
Inspec::Log.error 'Exception: ' + plugin_status.load_exception.class.name
Inspec::Log.error 'Trace: ' + plugin_status.load_exception.backtrace.join("\n")
end
end
Inspec::Log.error('Run again with --debug for a stacktrace.') unless ARGV.include?('--debug')
exit 2
end
end
def activate(plugin_type, hook_name)
activator = registry.find_activators(plugin_type: plugin_type, activation_name: hook_name).first
# We want to capture literally any possible exception here, since we are storing them.
# rubocop: disable Lint/RescueException
begin
impl_class = activator.activation_proc.call
activator.activated = true
activator.implementation_class = impl_class
rescue Exception => ex
activator.exception = ex
Inspec::Log.error "Could not activate #{activator.plugin_type} hook named '#{activator.activator_name}' for plugin #{plugin_name}"
end
# rubocop: enable Lint/RescueException
end
private
def annotate_status_after_loading(plugin_name)
status = registry[plugin_name]
return if status.api_generation == 2 # Gen2 have self-annotating superclasses
case status.installation_type
when :bundle
annotate_bundle_plugin_status_after_load(plugin_name)
else
# TODO: are there any other cases? can this whole thing be eliminated?
raise "I only know how to annotate :bundle plugins when trying to load plugin #{plugin_name}" unless status.installation_type == :bundle
end
end
def annotate_bundle_plugin_status_after_load(plugin_name)
# HACK: we're relying on the fact that all bundles are gen0 and cli type
status = registry[plugin_name]
status.api_generation = 0
act = Activator.new
act.activated = true
act.plugin_type = :cli_command
act.plugin_name = plugin_name
act.activator_name = :default
status.activators = [act]
v0_subcommand_name = plugin_name.to_s.gsub('inspec-', '')
status.plugin_class = Inspec::Plugins::CLI.subcommands[v0_subcommand_name][:klass]
end
def detect_bundled_plugins
bundle_dir = File.expand_path(File.join(File.dirname(__FILE__), '..', '..', '..', 'bundles'))
globs = [
File.join(bundle_dir, 'inspec-*.rb'),
File.join(bundle_dir, 'train-*.rb'),
]
Dir.glob(globs).each do |loader_file|
name = File.basename(loader_file, '.rb').gsub(/^(inspec|train)-/, '')
status = Inspec::Plugin::V2::Status.new
status.name = name
status.entry_point = loader_file
status.installation_type = :bundle
status.loaded = false
registry[name] = status
end
end
def determine_plugin_conf_file
@plugin_conf_file_path = ENV['INSPEC_CONFIG_DIR'] ? ENV['INSPEC_CONFIG_DIR'] : File.join(Dir.home, '.inspec')
@plugin_conf_file_path = File.join(@plugin_conf_file_path, 'plugins.json')
end
def read_conf_file
if File.exist?(@plugin_conf_file_path)
@plugin_file_contents = JSON.parse(File.read(@plugin_conf_file_path))
else
@plugin_file_contents = {
'plugins_config_version' => '1.0.0',
'plugins' => [],
}
end
rescue JSON::ParserError => e
raise Inspec::Plugin::V2::ConfigError, "Failed to load plugins JSON configuration from #{@plugin_conf_file_path}:\n#{e}"
end
def unpack_conf_file
validate_conf_file
@plugin_file_contents['plugins'].each do |plugin_json|
status = Inspec::Plugin::V2::Status.new
status.name = plugin_json['name'].to_sym
status.loaded = false
status.installation_type = plugin_json['installation_type'].to_sym || :gem
case status.installation_type
when :gem
status.entry_point = status.name
status.version = plugin_json['version']
when :path
status.entry_point = plugin_json['installation_path']
end
registry[status.name] = status
end
end
def validate_conf_file
unless @plugin_file_contents['plugins_config_version'] == '1.0.0'
raise Inspec::Plugin::V2::ConfigError, "Unsupported plugins.json file version #{@plugin_file_contents['plugins_config_version']} at #{@plugin_conf_file_path} - currently support versions: 1.0.0"
end
plugin_entries = @plugin_file_contents['plugins']
unless plugin_entries.is_a?(Array)
raise Inspec::Plugin::V2::ConfigError, "Malformed plugins.json file - should have a top-level key named 'plugins', whose value is an array"
end
plugin_entries.each do |plugin_entry|
unless plugin_entry.is_a? Hash
raise Inspec::Plugin::V2::ConfigError, "Malformed plugins.json file - each 'plugins' entry should be a Hash / JSON object"
end
unless plugin_entry.key? 'name'
raise Inspec::Plugin::V2::ConfigError, "Malformed plugins.json file - each 'plugins' entry must have a 'name' field"
end
next unless plugin_entry.key?('installation_type')
unless %w{gem path}.include? plugin_entry['installation_type']
raise Inspec::Plugin::V2::ConfigError, "Malformed plugins.json file - each 'installation_type' must be one of 'gem' or 'path'"
end
next unless plugin_entry['installation_type'] == 'path'
unless plugin_entry.key?('installation_path')
raise Inspec::Plugin::V2::ConfigError, "Malformed plugins.json file - each 'plugins' entry with a 'path' installation_type must provide an 'installation_path' field"
end
end
end
end
end

View file

@ -0,0 +1,98 @@
module Inspec::Plugin::V2
# Base class for all plugins. Specific plugin types *may* inherit from this; but they must register with it.
class PluginBase
# rubocop: disable Style/ClassVars
@@plugin_type_classes = {}
# rubocop: enable Style/ClassVars
#=====================================================================#
# Management Methods
#=====================================================================#
# The global registry.
# @returns [Inspec::Plugin::V2::Registry] the singleton Plugin Registry object.
def self.registry
Inspec::Plugin::V2::Registry.instance
end
# Inform the plugin system of a new plgin type.
# This has the following effects:
# * enables Inspec.plugin(2, :your_type_here) to return the plugin
# type base class
# * defines the DSL method with the same name as the plugin type.
#
# @ param [Symbol] plugin_type_name
def self.register_plugin_type(plugin_type_name)
new_dsl_method_name = plugin_type_name
new_plugin_type_base_class = self
# This lets the Inspec.plugin(2,:your_plugin) work
@@plugin_type_classes[plugin_type_name] = new_plugin_type_base_class
# This part defines the DSL command to register a concrete plugin's implementation of a plugin type
Inspec::Plugin::V2::PluginBase.define_singleton_method(new_dsl_method_name) do |hook_name, &hook_body|
plugin_concrete_class = self
# Verify class is registered (i.e. plugin_name has been called)
status = registry.find_status_by_class(plugin_concrete_class)
if status.nil?
raise Inspec::Plugin::V2::LoadError, "You must call 'plugin_name' prior to calling #{plugin_type_name} for plugin class #{plugin_concrete_class}"
end
# Construct the Activator record
activator = Inspec::Plugin::V2::Activator.new
activator.plugin_name = plugin_concrete_class.plugin_name
activator.plugin_type = plugin_type_name
activator.activator_name = hook_name.to_sym
activator.activation_proc = hook_body
status.activators << activator
end
end
# Determine the base class for a given plugin type
# @param [Symbol] plugin_type_name
# @returns [Class] the plugin type base class
def self.base_class_for_type(plugin_type_name)
@@plugin_type_classes[plugin_type_name]
end
#=====================================================================#
# DSL Methods
#=====================================================================#
# If no name provided, looks up a known plugin by class and returns the name.
#
# DSL method to declare a plugin. Once this has been called, the plugin will certainly be
# registered (known) with the Registry, and is eligible to be activated.
# This mainly handles status annotation.
#
# @param [Symbol] Name of the plugin. If a string is provided, it is converted to a Symbol.
# @returns [Symbol] Name of the plugin
def self.plugin_name(name = nil)
reg = Inspec::Plugin::V2::Registry.instance
return reg.find_status_by_class(self).name if name.nil?
name = name.to_sym
# Typically our status will already exist in the registry, from loading the
# plugin.json. If we're being loaded, presumably entry_point,
# installation_type, version
# are known.
unless reg.known_plugin?(name)
# Under some testing situations, we may not pre-exist.
status = Inspec::Plugin::V2::Status.new
reg.register(name, status)
status.entry_point = 'inline'
status.installation_type = :mock_inline
end
status = reg[name]
status.api_generation = 2
status.plugin_class = self
status.name = name
name
end
end
end

View file

View file

@ -0,0 +1,12 @@
module Inspec::Plugin::V2::PluginType
# Test plugin type
class Mock < Inspec::Plugin::V2::PluginBase
register_plugin_type(:mock_plugin_type)
# This is the API for the mock plugin type: when a mock plugin is
# activated, it is expected to be able to respond to this, and "do something"
def mock_hook
raise NotImplementedError, 'Mock plugins must implement mock_hook'
end
end
end

View file

@ -0,0 +1,75 @@
require 'forwardable'
require 'singleton'
require_relative 'status'
require_relative 'activator'
module Inspec::Plugin::V2
class Registry
include Singleton
extend Forwardable
attr_reader :registry
def_delegator :registry, :each
def_delegator :registry, :[]
def_delegator :registry, :key?, :known_plugin?
def_delegator :registry, :keys, :plugin_names
def_delegator :registry, :values, :plugin_statuses
def_delegator :registry, :select
def initialize
@registry = {}
end
def any_load_failures?
!plugin_statuses.select(&:load_exception).empty?
end
def loaded_plugin?(name)
registry.dig(name, :loaded)
end
def loaded_count
registry.values.select(&:loaded).count
end
def known_count
registry.values.count
end
def loaded_plugin_names
registry.values.select(&:loaded).map(&:name)
end
def find_status_by_class(klass)
registry.values.detect { |status| status.plugin_class == klass }
end
# Finds Activators matching criteria (all optional) you specify as a Hash.
# @param [Symbol] plugin_name Restricts the search to the given plugin
# @param [Symbol] plugin_type Restricts the search to the given plugin type
# @param [Symbol] activator_name Name of the activator
# @returns [Array] Possibly empty array of Activators
def find_activators(filters = {})
plugin_statuses.map(&:activators).flatten.select do |act|
[:plugin_name, :plugin_type, :activator_name].all? do |criteria|
!filters.key?(criteria) || act[criteria] == filters[criteria]
end
end
end
def register(name, status)
if known_plugin? name
Inspec::Log.warn "PluginLoader: refusing to re-register plugin '#{name}': an existing plugin with that name was loaded via #{existing.installation_type}-loading from #{existing.entry_point}"
else
registry[name.to_sym] = status
end
end
alias []= register
# Provided for test support. Purges the registry.
def __reset
@registry.clear
end
end
end

View file

@ -0,0 +1,29 @@
module Inspec::Plugin::V2
# Track loading status of each plugin. These are the elements of the Registry.
#
# Lifecycle of an installed plugin:
# If present in the config file, bundled, or core, it is "known"
# All known plugins are loaded. v1 plugins auto-activate. All loaded plugins know their version.
# v2 plugins activate when they are used. All activated plugins know their implementation class.
Status = Struct.new(
:activators, # Array of Activators - where plugin_type info gets stored
:api_generation, # 0,1,2 # TODO: convert all bundled (v0) to v2
:plugin_class, # Plugin class
:entry_point, # a gem name or filesystem path
:installation_type, # :gem, :path, :core, bundle # TODO: combine core and bundle
:loaded, # true, false False could mean not attempted or failed
:load_exception, # Exception class if it failed to load
:name, # String name
:version, # three-digit version. Core / bundled plugins use InSpec version here.
) do
def initialize(*)
super
self[:activators] = []
self[:loaded] = false
end
def plugin_types
activators.map(&:plugin_type).uniq.sort
end
end
end

View file

@ -2,7 +2,7 @@
# copyright: 2015, Vulcano Security GmbH
# author: Dominik Richter
# author: Christoph Hartmann
require 'inspec/plugins'
require 'inspec/plugin/v1'
module Inspec
class ProfileNotFound < StandardError; end

View file

@ -2,8 +2,7 @@
# author: Christoph Hartmann
# author: Dominik Richter
require 'inspec/plugins'
require 'utils/plugin_registry'
require 'inspec/plugin/v1'
module Inspec
SecretsBackend = PluginRegistry.new

View file

@ -2,8 +2,7 @@
# author: Dominik Richter
# author: Christoph Hartmann
require 'inspec/plugins'
require 'utils/plugin_registry'
require 'inspec/plugin/v1'
module Inspec
# Pre-checking of target resolution. Make sure that SourceReader plugins

View file

@ -16,7 +16,8 @@ end
module FunctionalHelper
let(:repo_path) { File.expand_path(File.join( __FILE__, '..', '..', '..')) }
let(:exec_inspec) { File.join(repo_path, 'bin', 'inspec') }
let(:profile_path) { File.join(repo_path, 'test', 'unit', 'mock', 'profiles') }
let(:mock_path) { File.join(repo_path, 'test', 'unit', 'mock') }
let(:profile_path) { File.join(mock_path, 'profiles') }
let(:examples_path) { File.join(repo_path, 'examples') }
let(:integration_test_path) { File.join(repo_path, 'test', 'integration', 'default') }
@ -26,6 +27,7 @@ module FunctionalHelper
let(:failure_control) { File.join(profile_path, 'failures', 'controls', 'failures.rb') }
let(:simple_inheritance) { File.join(profile_path, 'simple-inheritance') }
let(:sensitive_profile) { File.join(examples_path, 'profile-sensitive') }
let(:config_dir_path) { File.join(mock_path, 'config_dirs') }
let(:dst) {
# create a temporary path, but we only want an auto-clean helper
@ -39,6 +41,15 @@ module FunctionalHelper
CMD.run_command("#{prefix} #{exec_inspec} #{commandline}")
end
def inspec_with_env(commandline, env = {})
# CMD is a train transport, and does not support anything other than a
# single param for the command line.
# TODO: what is the intent of using Train here?
# HACK: glue together env vars
env_prefix = env.to_a.map { |assignment| "#{assignment[0]}=#{assignment[1]}" }.join(' ')
CMD.run_command("#{env_prefix} #{exec_inspec} #{commandline}")
end
# Copy all examples to a temporary directory for functional tests.
# You can provide an optional directory which will be handed to your
# test block with its absolute path. If nothing is provided you will

View file

@ -0,0 +1,49 @@
# Functional tests related to plugin facility
require 'functional/helper'
#=========================================================================================#
# Loader Errors
#=========================================================================================#
describe 'plugin loader' do
include FunctionalHelper
it 'handles a corrupt plugins.json correctly' do
outcome = inspec_with_env('version', INSPEC_CONFIG_DIR: File.join(config_dir_path, 'corrupt'))
outcome.exit_status.must_equal 2
outcome.stdout.wont_include('Inspec::Plugin::V2::ConfigError', 'No stacktrace in error by default')
outcome.stdout.must_include('Failed to load plugins JSON configuration', 'Friendly message in error')
outcome.stdout.must_include('unit/mock/config_dirs/corrupt/plugins.json', 'Location of bad file in error')
outcome = inspec_with_env('version --debug', INSPEC_CONFIG_DIR: File.join(config_dir_path, 'corrupt'))
outcome.exit_status.must_equal 2
outcome.stdout.must_include('Inspec::Plugin::V2::ConfigError', 'Include stacktrace in error with --debug')
end
it 'handles a misversioned plugins.json correctly' do
outcome = inspec_with_env('version', INSPEC_CONFIG_DIR: File.join(config_dir_path, 'bad_plugin_conf_version'))
outcome.exit_status.must_equal 2
outcome.stdout.wont_include('Inspec::Plugin::V2::ConfigError', 'No stacktrace in error by default')
outcome.stdout.must_include('Unsupported plugins.json file version', 'Friendly message in error')
outcome.stdout.must_include('unit/mock/config_dirs/bad_plugin_conf_version/plugins.json', 'Location of bad file in error')
outcome.stdout.must_include('99.99.9', 'Incorrect version in error')
outcome = inspec_with_env('version --debug', INSPEC_CONFIG_DIR: File.join(config_dir_path, 'bad_plugin_conf_version'))
outcome.exit_status.must_equal 2
outcome.stdout.must_include('Inspec::Plugin::V2::ConfigError', 'Include stacktrace in error with --debug')
end
it 'handles an unloadable plugin correctly' do
outcome = inspec_with_env('version', INSPEC_CONFIG_DIR: File.join(config_dir_path, 'plugin_error_on_load'))
outcome.exit_status.must_equal 2
outcome.stdout.must_include('ERROR', 'Have an error on stdout')
outcome.stdout.must_include('Could not load plugin inspec-divide-by-zero', 'Name the plugin in the stdout error')
outcome.stdout.wont_include('ZeroDivisionError', 'No stacktrace in error by default')
outcome.stdout.must_include('Errors were encountered while loading plugins', 'Friendly message in error')
outcome.stdout.must_include('Plugin name: inspec-divide-by-zero', 'Plugin named in error')
outcome.stdout.must_include('divided by 0', 'Exception message in error')
outcome = inspec_with_env('version --debug', INSPEC_CONFIG_DIR: File.join(config_dir_path, 'plugin_error_on_load'))
outcome.exit_status.must_equal 2
outcome.stdout.must_include('ZeroDivisionError', 'Include stacktrace in error with --debug')
end
end

View file

@ -0,0 +1,4 @@
{
"plugins_config_version" : "99.99.9",
"plugins": []
}

View file

@ -0,0 +1,2 @@
{
"plugins_config_version" :

View file

@ -0,0 +1,22 @@
{
"plugins_config_version" : "1.0.0",
"plugins": [
{
"name": "inspec-test-plugin-gem",
"installation_type": "gem",
"version": "0.1.0"
},
{
"name": "inspec-test-plugin-path",
"installation_type": "path",
"installation_path": "test/unit/mock/plugins/inspec-test-plugin-path",
"version": "0.1.0"
},
{
"name": "inspec-test-home-marker",
"installation_type": "path",
"installation_path": "test/unit/mock/plugins/inspec-test-home-marker",
"version": "0.1.0"
}
]
}

View file

@ -0,0 +1,10 @@
{
"plugins_config_version" : "1.0.0",
"plugins": [
{
"name": "inspec-meaning-of-life",
"installation_type": "path",
"installation_path": "test/unit/mock/plugins/meaning_of_life_path_mode/inspec-meaning-of-life"
}
]
}

View file

@ -0,0 +1,10 @@
{
"plugins_config_version" : "1.0.0",
"plugins": [
{
"name": "inspec-divide-by-zero",
"installation_type": "path",
"installation_path": "test/unit/mock/plugins/inspec-divide-by-zero/inspec-divide-by-zero"
}
]
}

View file

@ -0,0 +1,13 @@
module InspecPlugins
module MeaningOfLife
class Cli < Inspec.plugin(2, :cli)
# Do cli-ish things
def execute(opts)
puts 'The answer to life, the universe, and everything:'
puts '42'
end
end
end
end

View file

@ -0,0 +1,13 @@
module InspecPlugins
module MeaningOfLife
class Plugin < Inspec.plugin(2)
plugin_name :meaning_of_life
# cli 'meaning-of-life-the-universe-and-everything' do
# require_relative './cli'
# InspecPlugins::MeaningOfLife::Cli
# end
end
end
end

View file

@ -0,0 +1,2 @@
# NOTE: we can't use require, because these test files are repeatedly reloaded
load 'test/unit/mock/plugins/meaning_of_life_path_mode/inspec-meaning-of-life/plugin.rb'

View file

@ -0,0 +1,12 @@
module InspecPlugins
module MeaningOfLife
class MockPlugin < Inspec.plugin(2, :mock_plugin_type)
# Do mockish things
def execute(opts)
return 42
end
end
end
end

View file

@ -0,0 +1,15 @@
module InspecPlugins
module MeaningOfLife
class Plugin < Inspec.plugin(2)
plugin_name :'inspec-meaning-of-life'
mock_plugin_type 'meaning-of-life-the-universe-and-everything' do
# NOTE: we can't use require, because these test files are repeatedly reloaded
load 'test/unit/mock/plugins/meaning_of_life_path_mode/inspec-meaning-of-life/mock_plugin.rb'
InspecPlugins::MeaningOfLife::MockPlugin
end
end
end
end

View file

@ -7,7 +7,7 @@ require 'minitest/autorun'
require 'minitest/spec'
require 'mocha/setup'
require 'inspec/plugins/cli'
require 'inspec/plugin/v1/plugin_types/cli'
require 'thor'
describe 'plugin system' do

View file

@ -0,0 +1,98 @@
require 'minitest/autorun'
require 'minitest/test'
require_relative '../../../../lib/inspec/plugin/v2'
class PluginV2VersionedApiTests < MiniTest::Test
# you can call Inspec.plugin(2) and get the plugin base class
def test_calling_Inspec_dot_plugin_with_2_returns_the_plugin_base_class
klass = Inspec.plugin(2)
assert_kind_of Class, klass
assert_equal 'Inspec::Plugin::V2::PluginBase', klass.name
end
def test_calling_Inspec_dot_plugin_with_2_and_mock_plugin_returns_the_mock_plugin_base_class
klass = Inspec.plugin(2, :mock_plugin_type)
assert_kind_of Class, klass, '2-arg form of Inspec.plugin() should return a specific plugin type base class'
assert_equal 'Inspec::Plugin::V2::PluginType::Mock', klass.name
end
end
class PluginV2BaseMgmtMethods < MiniTest::Test
def test_plugin_v2_management_class_methods_present
[
:base_class_for_type,
:registry,
:register_plugin_type,
:plugin_name,
].each do |method_name|
klass = Inspec::Plugin::V2::PluginBase
assert_respond_to klass, method_name, "Base class plugin management class method: #{method_name}"
end
end
def test_plugin_type_base_classes_can_be_accessed_by_name
klass = Inspec::Plugin::V2::PluginBase.base_class_for_type(:mock_plugin_type)
assert_kind_of Class, klass, 'base_class_for_type should work for mock_plugin_type'
assert_equal 'Inspec::Plugin::V2::PluginType::Mock', klass.name
end
end
class PluginV2BaseDslMethods < MiniTest::Test
def test_plugin_v2_dsl_methods_present
[
:plugin_name,
:mock_plugin_type,
# [ :attribute_provider, :platform, :fetcher, :source_reader, :control_dsl, :reporter ]
].each do |method_name|
klass = Inspec::Plugin::V2::PluginBase
assert_respond_to klass, method_name, 'Plugin DSL methods'
end
end
def test_when_calling_plugin_name_the_plugin_is_registered
test_plugin_name = :dsl_plugin_name_test
reg = Inspec::Plugin::V2::Registry.instance
refute reg.known_plugin?(test_plugin_name), 'should not know plugin name in advance'
assert_equal 0, reg.loaded_count, 'Should start with no plugins loaded'
assert_equal 0, reg.known_count, 'Should start with no plugins known'
assert_raises(Inspec::Plugin::V2::LoadError, 'plugin definitions must include the plugin_name call') do
# Make a plugin class, including calling the plugin type DSL definition method, but do not call plugin_name
Class.new(Inspec.plugin(2)) do
# Plugin class body
mock_plugin_type :dsl_plugin_name_test do
Class.new(Inspec.plugin(2, :mock_plugin_type))
end
end
end
refute reg.known_plugin?(test_plugin_name), 'failing to load a nameless plugin should not somehow register the plugin'
assert_equal 0, reg.loaded_count, 'Should have no plugins loaded after failing to load a nameless plugin'
assert_equal 0, reg.known_count, 'Should have no plugins known after failing to load a nameless plugin'
# Now create another plugin class, but this time *do* call plugin_name
name_provided_class = Class.new(Inspec.plugin(2)) do
# Plugin class body
plugin_name :dsl_plugin_name_test
mock_plugin_type :dsl_plugin_name_test do
Class.new(Inspec.plugin(2, :mock_plugin_type))
end
end
assert reg.known_plugin?(test_plugin_name), 'plugin name should register the plugin'
assert_equal 0, reg.loaded_count, 'plugin_name should not load the plugin'
assert_equal 1, reg.known_count, 'plugin_name should cause one plugin to be known'
status = reg[test_plugin_name]
assert_equal name_provided_class, status.plugin_class
assert_equal 2, status.api_generation
assert_includes status.plugin_types, :mock_plugin_type
end
def test_plugin_type_registers_an_activation_dsl_method
klass = Inspec::Plugin::V2::PluginBase
assert_respond_to klass, :mock_plugin_type, 'Activation method for mock_plugin_type'
end
end

View file

@ -0,0 +1,36 @@
require 'minitest/autorun'
require 'minitest/test'
require_relative '../../../../lib/inspec'
module PluginV2BackCompat
class PluginV1TypeClassFetchers < MiniTest::Test
# Note: we can't call klass.name, because that is redefined as a setter.
# cli had a base class (which was really a registry), but no class fetcher
# There was no Inspec.plugin(...)
def test_get_plugin_v1_base_for_fetchers
klass = Inspec.fetcher(1)
assert_kind_of Class, klass
assert Inspec::Plugins.const_defined? :Fetcher
assert_equal Inspec::Plugins::Fetcher, klass
end
def test_get_plugin_v1_base_for_source_readers
klass = Inspec.source_reader(1)
assert_kind_of Class, klass
assert Inspec::Plugins.const_defined? :SourceReader
assert_equal Inspec::Plugins::SourceReader, klass
end
# TODO: rename to attribute_provider?
def test_get_plugin_v1_base_for_secrets
klass = Inspec.secrets(1)
assert_kind_of Class, klass
assert Inspec::Plugins.const_defined? :Secret
assert_equal Inspec::Plugins::Secret, klass
end
end
end

View file

@ -0,0 +1,184 @@
# Unit tests for Inspec::PluginLoader and Registry
require 'minitest/autorun'
require 'minitest/test'
require_relative '../../../../lib/inspec/plugin/v2'
class PluginLoaderTests < MiniTest::Test
@@orig_home = Dir.home
def reset_globals
# These are effectively globals
Inspec::Plugin::V2::Registry.instance.__reset
ENV['HOME'] = @@orig_home
ENV['INSPEC_CONFIG_DIR'] = nil
end
def setup
reset_globals
repo_path = File.expand_path(File.join( __FILE__, '..', '..', '..', '..', '..'))
mock_path = File.join(repo_path, 'test', 'unit', 'mock')
@config_dir_path = File.join(mock_path, 'config_dirs')
@bundled_plugins = [
:artifact,
:compliance,
:habitat,
:init,
:supermarket,
]
end
def teardown
reset_globals
end
#====================================================================#
# basic constructor usage and bundle detection #
#====================================================================#
def test_constructor_should_not_load_anything_automatically
reg = Inspec::Plugin::V2::Registry.instance
loader = Inspec::Plugin::V2::Loader.new
assert_equal 0, reg.loaded_count, "\nRegistry load count"
end
def test_constructor_should_detect_bundled_plugins
reg = Inspec::Plugin::V2::Registry.instance
loader = Inspec::Plugin::V2::Loader.new
@bundled_plugins.each do |bundled_plugin_name|
assert reg.known_plugin?(bundled_plugin_name), "\n#{bundled_plugin_name} should be detected as a bundled plugin"
end
end
def test_constructor_should_skip_bundles_when_option_is_set
reg = Inspec::Plugin::V2::Registry.instance
loader = Inspec::Plugin::V2::Loader.new(omit_bundles: true)
@bundled_plugins.each do |bundled_plugin_name|
refute reg.known_plugin?(bundled_plugin_name), "\n#{bundled_plugin_name} should not be detected when omit_bundles is set"
end
end
def test_constructor_when_using_home_dir_detects_declared_plugins
ENV['HOME'] = File.join(@config_dir_path, 'fakehome')
reg = Inspec::Plugin::V2::Registry.instance
loader = Inspec::Plugin::V2::Loader.new
assert reg.known_plugin?(:'inspec-test-home-marker'), "\ninspec-test-home-marker should be detected as a plugin"
end
#====================================================================#
# unusual plugin.json situations #
#====================================================================#
def test_constructor_when_the_plugin_config_is_absent_it_detects_bundled_plugins
ENV['INSPEC_CONFIG_DIR'] = File.join(@config_dir_path, 'empty')
reg = Inspec::Plugin::V2::Registry.instance
loader = Inspec::Plugin::V2::Loader.new
@bundled_plugins.each do |bundled_plugin_name|
assert reg.known_plugin?(bundled_plugin_name), "\n#{bundled_plugin_name} should be detected as a bundled plugin"
end
end
def test_constuctor_when_the_plugin_config_is_corrupt_it_throws_an_exception
ENV['INSPEC_CONFIG_DIR'] = File.join(@config_dir_path, 'corrupt')
assert_raises(Inspec::Plugin::V2::ConfigError) { Inspec::Plugin::V2::Loader.new }
end
def test_constuctor_when_the_plugin_config_is_a_bad_version_it_throws_an_exception
ENV['INSPEC_CONFIG_DIR'] = File.join(@config_dir_path, 'bad_plugin_conf_version')
assert_raises(Inspec::Plugin::V2::ConfigError) { Inspec::Plugin::V2::Loader.new }
end
#====================================================================#
# basic loading #
#====================================================================#
def test_load_no_plugins_should_load_no_plugins
reg = Inspec::Plugin::V2::Registry.instance
loader = Inspec::Plugin::V2::Loader.new(omit_bundles: true)
loader.load_all
assert_equal 0, reg.loaded_count, "\nRegistry load count"
end
def test_load_only_bundled_plugins_should_load_bundled_plugins
skip 'This keeps failing, only affects legacy bundles, will fix later'
# Skip rationale: I beleive this test is failing due to a test artifact - we
# keep loading v1 CLI plugins and then purging the registry, which results (depending
# on test order) in the Ruby `require` refusing to re-load the v1 plugin (since it was
# previously loaded). But since we purge the Registry, the Registry doesn't know
# about it either. Neither of those things are intended to happen as
# the plugin system is finished (the v1 plugins will be ported to v2, and registry
# purging should never happen in real-world use)
reg = Inspec::Plugin::V2::Registry.instance
loader = Inspec::Plugin::V2::Loader.new
loader.load_all
@bundled_plugins.each do |bundled_plugin_name|
assert reg.loaded_plugin?(bundled_plugin_name), "\n#{bundled_plugin_name} should be loaded"
assert_equal [ :cli_command ], reg[bundled_plugin_name].plugin_types, "annotate plugin type of bundled plugins"
assert_equal 0, reg[bundled_plugin_name].api_generation, "annotate API generation of bundled plugins"
assert_kind_of(Class, reg[bundled_plugin_name].plugin_class)
end
assert_equal @bundled_plugins.count, reg.loaded_count, "\nRegistry load count"
end
def test_load_cli_plugin_by_path
ENV['INSPEC_CONFIG_DIR'] = File.join(@config_dir_path, 'meaning_by_path')
reg = Inspec::Plugin::V2::Registry.instance
plugin_name = :'inspec-meaning-of-life'
loader = Inspec::Plugin::V2::Loader.new(omit_bundles: true)
assert reg.known_plugin?(plugin_name), "\n#{plugin_name} should be a known plugin"
refute reg.loaded_plugin?(plugin_name), "\n#{plugin_name} should not be loaded yet"
loader.load_all
assert reg.loaded_plugin?(plugin_name), "\n#{plugin_name} should be loaded"
end
#====================================================================#
# activation #
#====================================================================#
def test_activation
# Setup
ENV['INSPEC_CONFIG_DIR'] = File.join(@config_dir_path, 'meaning_by_path')
registry = Inspec::Plugin::V2::Registry.instance
loader = Inspec::Plugin::V2::Loader.new(omit_bundles: true)
loader.load_all
status = registry[:'inspec-meaning-of-life']
# Management methods for activation
assert_respond_to status, :activators, 'A plugin status should respond to `activators`'
assert_respond_to registry, :find_activators, 'Registry should respond to `find_activators`'
assert_respond_to loader, :activate, 'Loader should respond to `activate`'
# Finding an Activator
assert_kind_of Array, status.activators, 'status should have an array for activators'
assert_kind_of Array, registry.find_activators(), 'find_activators should return an array'
assert_equal 'Inspec::Plugin::V2::Activator', registry.find_activators()[0].class.name, 'find_activators should return an array of Activators'
activator = registry.find_activators(plugin_type: :mock_plugin_type, name: :'meaning-of-life-the-universe-and-everything')[0]
refute_nil activator, 'find_activators should find the test activator'
[ :plugin_name, :plugin_type, :activator_name, :activated, :exception, :activation_proc, :implementation_class ].each do |method_name|
assert_respond_to activator, method_name
end
# Activation preconditions
refute activator.activated, 'Test activator should start out unactivated'
assert_nil activator.exception, 'Test activator should have no exception prior to activation'
assert_nil activator.implementation_class, 'Test activator should not know implementation class prior to activation'
refute InspecPlugins::MeaningOfLife.const_defined?(:MockPlugin), 'impl_class should not be defined prior to activation'
loader.activate(:mock_plugin_type, 'meaning-of-life-the-universe-and-everything')
# Activation postconditions
assert activator.activated, 'Test activator should be activated after activate'
assert_nil activator.exception, 'Test activator should have no exception after activation'
# facts about the implementation class
impl_class = activator.implementation_class
refute_nil impl_class, 'Activation should set the implementation class'
assert_kind_of Class, impl_class, 'Should have a Class in the implementation class slot'
assert_includes impl_class.ancestors, Inspec::Plugin::V2::PluginBase, 'impl_class should derive from PluginBase'
assert_includes impl_class.ancestors, Inspec::Plugin::V2::PluginType::Mock, 'impl_class should derive from PluginType::Mock'
assert InspecPlugins::MeaningOfLife.const_defined?(:MockPlugin), 'impl_class should now be defined'
end
end