mirror of
https://github.com/inspec/inspec
synced 2025-02-16 22:18:38 +00:00
Plugins V2 API: CLI Command Plugin Type, Again (#3296)
Plugins V2 API: CLI Command Plugin Type Signed-off-by: Clinton Wolfe <clintoncwolfe@gmail.com>
This commit is contained in:
parent
811318f2f8
commit
d24e0f0ec9
11 changed files with 316 additions and 17 deletions
|
@ -114,20 +114,47 @@ module InspecPlugins
|
|||
# Must match entry in plugins.json
|
||||
plugin_name :'inspec-my-plugin'
|
||||
|
||||
# Activation hooks
|
||||
# TODO
|
||||
# Activation hooks (CliCommand as an example)
|
||||
cli_command :'my-command' do
|
||||
require_relative 'cli'
|
||||
InspecPlugins::MyPlugin::CliCommand
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Note that the block passed to `cli_command` is not executed when the plugin definition is loaded. It will only be executed if inspec decides it needs to activate that plugin component.
|
||||
|
||||
Every activation hook is expected to return a `Class` which will be used in post-activation or execution phases. The behavior, duck typing, and superclass of that Class vary depending on the plugin type; see below for details.
|
||||
|
||||
### Implementation Files
|
||||
|
||||
TODO
|
||||
Inside the implementation files, you should be sure to do three things:
|
||||
|
||||
1. Load any heavyweight libraries your plugin needs
|
||||
2. Create a class (which you will return from the activator hook)
|
||||
3. Within the class, implement your functionality, as dictated by the plugin type API
|
||||
|
||||
```ruby
|
||||
# lib/inspec-my-plugin/cli.rb
|
||||
|
||||
# Load enormous dependencies
|
||||
require_relative 'heavyweight'
|
||||
|
||||
module InspecPlugin::MyPlugin
|
||||
# Class name doesn't matter, but this is a reasonable default name
|
||||
class CliCommand < Inspec.plugin(2, :cli_command) # Note two-arg form
|
||||
# Implement API or use DSL as dictated by cli_command plugin type
|
||||
# ...
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Plugin Lifecycle
|
||||
|
||||
All queries regarding plugin state should be directed to Inspec::Plugin::V2::Registry.instance, a singleton object.
|
||||
All queries regarding plugin state should be directed to `Inspec::Plugin::V2::Registry.instance`, a singleton object.
|
||||
|
||||
```ruby
|
||||
registry = Inspec::Plugin::V2::Registry.instance
|
||||
|
@ -136,7 +163,7 @@ 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.
|
||||
If a plugin is mentioned in `plugins.json` or is a plugin distributed with InSpec itself, 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.
|
||||
|
||||
|
@ -148,14 +175,147 @@ If things go right, the Status now has a bunch of Activators, each with a block
|
|||
|
||||
If things go wrong, have a look at `status.load_exception`.
|
||||
|
||||
### Activation
|
||||
### Activation and Execution
|
||||
|
||||
Depending on the plugin type, activation may be triggered by a number of different events.
|
||||
Depending on the plugin type, activation may be triggered by a number of different events. For example, CliCommand plugin types are activated when their activation name is mentioned in the command line arguments.
|
||||
|
||||
TODO
|
||||
After activation, code for that aspect of the plugin is loaded and ready to execute. Execution may be triggered by a number of different events. For example, the CliCommand plugin types are implicitly executed by Thor when `Inspec::CLI` calls `start()`.
|
||||
|
||||
### Execution
|
||||
Refer to the sections below for details about activation and execution timing.
|
||||
|
||||
Depending on the plugin type, execution may be triggered by a number of different events.
|
||||
## Implementing a CLI Command Plugin
|
||||
|
||||
TODO
|
||||
The CliCommand plugin_type allows you to extend the InSpec command line interface by adding a namespace of new commands. InSpec is based on [Thor](http://whatisthor.com/) ([docs](https://www.rubydoc.info/github/wycats/thor/Thor)), and the plugin system exposes Thor directly.
|
||||
|
||||
CliCommand can do things like:
|
||||
|
||||
```bash
|
||||
# A namespaced custom command with options
|
||||
you@machine$ inspec sweeten add --kind sugar --teaspoons 2
|
||||
# A namespaced custom command with short options
|
||||
you@machine$ inspec sweeten add -k agave
|
||||
# Mix global and namespace options
|
||||
you@machine$ inspec --debug sweeten add -k aspartame
|
||||
# Namespace included in help
|
||||
you@machine$ inspec help
|
||||
Commands:
|
||||
inspec archive PATH # archive a profile to tar.gz (default) or zip
|
||||
inspec sweeten ... # Add spoonfuls til the medicine goes down
|
||||
# Detailed help
|
||||
[cwolfe@lodi inspec-plugins]$ inspec help sweeten
|
||||
Commands:
|
||||
inspec sweeten add [opts] # Adds sweetener to your beverage
|
||||
inspec sweeten count # Reports on teaspoons in your beverage, always bad news
|
||||
```
|
||||
|
||||
Currently, it cannot create a direct (non-namespaced) command, such as `inspec mycommand` with no subcommands.
|
||||
|
||||
### Declare your plugin activators
|
||||
|
||||
In your `plugin.rb`, include one or more `cli_command` activation blocks. The activation block name will be matched against the command line arguments; if the name is present, your activator will fire (in which case it should load any needed libraries) and should return your implementation class.
|
||||
|
||||
#### CliCommand Activator Example
|
||||
|
||||
```ruby
|
||||
|
||||
# In plugin.rb
|
||||
module InspecPlugins::Sweeten
|
||||
class Plugin < Inspec.plugin(2)
|
||||
# ... other plugin stuff
|
||||
|
||||
cli_command :sweeten do
|
||||
require_relative 'cli.rb'
|
||||
InspecPlugins::Sweeten::CliCommand
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Like any activator, the block above will only be called if needed. For CliCommand plugins, the plugin system naively scans through ARGV, looking for the activation name as a whole element. Multiple CliCommand activations may occur if several different names match, though each activation will only occur once.
|
||||
|
||||
```bash
|
||||
you@machine $ inspec sweeten ... # Your CliCommand implementation is activated and executed
|
||||
you@machine $ inspec exec ... # Your CliCommand implementation is not activated
|
||||
```
|
||||
|
||||
Execution occurs implicitly via `Thor.start()`, which is handled by `bin/inspec`. Keep reading.
|
||||
|
||||
You should also be aware of one other activation event: if the CLI is invoked as `inspec help`, *all* CliCommand plugins will activate (but will not be executed). This is so that each plugin's help information can be registered with Thor.
|
||||
|
||||
### Implementation class for CLI Commands
|
||||
|
||||
In your `cli.rb`, you should begin by requesting the superclass from `Inspec.plugin`:
|
||||
|
||||
```ruby
|
||||
module InspecPlugins::Sweeten
|
||||
class CliCommand < Inspec.plugin(2, :cli_command)
|
||||
# ...
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
The Inspec plugin v2 system promises the following:
|
||||
|
||||
* The superclass will be an (indirect) subclass of Thor
|
||||
* The plugin system will handle registering the subcommand with Thor for you
|
||||
* The plugin system will handle setup of the subcommand help message for you
|
||||
|
||||
### Implementing your command
|
||||
|
||||
Within your `cli.rb`, you need to do two things:
|
||||
|
||||
* Inform Inspec of your subcommand's usage and description, so the `help` commands will work properly
|
||||
* Implement your subcommands and options using the Thor DSL
|
||||
|
||||
See also: [Thor homepage](http://whatisthor.com/) and [Thor docs](https://www.rubydoc.info/github/wycats/thor/Thor).
|
||||
|
||||
#### Call subcommand_desc
|
||||
|
||||
Within your implementation, make a call like this:
|
||||
|
||||
```ruby
|
||||
# Class declaration as above
|
||||
subcommand_desc 'sweeten ...', 'Add spoonfuls til the medicine goes down'
|
||||
```
|
||||
|
||||
The first argument is the usage message; it will be displayed whenever you execute `inspec help`, or when Thor tries to parse a malformed `inspec sweeten ...` command.
|
||||
|
||||
The second is the command groups description, and is displayed with `inspec help`.
|
||||
|
||||
Both arguments are free-form Strings intended for humans; the usage message should begin with your subcommand name to prevent user confusion.
|
||||
|
||||
If you neglect to call this DSL method, Thor will not register your command.
|
||||
|
||||
#### Adding Subcommands
|
||||
|
||||
The minimum needed for a command is a call to `desc` to set the help message, and a method definition named after the command.
|
||||
|
||||
```ruby
|
||||
desc 'Reports on teaspoons in your beverage, always bad news'
|
||||
def count
|
||||
# Someone has executed `inspec sweeten count` - do whatever that entails
|
||||
case beverage_type
|
||||
when :soda
|
||||
puts 12
|
||||
when :tea_two_lumps
|
||||
puts 2
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
There is a great deal more you can do with Thor, especially concerning handling options. Refer to the Thor docs for more examples and details.
|
||||
|
||||
#### Using no_command
|
||||
|
||||
One common surprise seen with Thor is that every public instance method of your CliCommand implementation class is expected to be a CLI command definition. Thor will issue a warning if it encounters a public method definition without a `desc` call preceding it. Two ways around this include:
|
||||
|
||||
* Make your helper methods private
|
||||
* Enclose your non-command methods in a no_command block (a feature of Thor just for this circumstance)
|
||||
|
||||
```ruby
|
||||
no_command do
|
||||
def beverage_type
|
||||
@bevvy
|
||||
end
|
||||
end
|
||||
```
|
|
@ -288,6 +288,7 @@ begin
|
|||
v2_loader = Inspec::Plugin::V2::Loader.new
|
||||
v2_loader.load_all
|
||||
v2_loader.exit_on_load_error
|
||||
v2_loader.activate_mentioned_cli_plugins
|
||||
|
||||
# Load v1 plugins on startup
|
||||
ctl = Inspec::PluginCtl.new
|
||||
|
|
|
@ -74,6 +74,19 @@ module Inspec::Plugin::V2
|
|||
# rubocop: enable Lint/RescueException
|
||||
end
|
||||
|
||||
def activate_mentioned_cli_plugins(cli_args = ARGV)
|
||||
# Get a list of CLI plugin activation hooks
|
||||
registry.find_activators(plugin_type: :cli_command).each do |act|
|
||||
next if act.activated
|
||||
# If there is anything in the CLI args with the same name, activate it
|
||||
# If the word 'help' appears in the first position, load all CLI plugins
|
||||
if cli_args.include?(act.activator_name.to_s) || cli_args[0] == 'help'
|
||||
activate(:cli_command, act.activator_name)
|
||||
act.implementation_class.register_with_thor
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def annotate_status_after_loading(plugin_name)
|
||||
|
|
|
@ -21,12 +21,12 @@ module Inspec::Plugin::V2
|
|||
# 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)
|
||||
# @param [Symbol] plugin_type_name
|
||||
# @param [Class] the plugin type class, defaults to assuming inheritance
|
||||
def self.register_plugin_type(plugin_type_name, new_plugin_type_base_class = self)
|
||||
new_dsl_method_name = plugin_type_name
|
||||
new_plugin_type_base_class = self
|
||||
|
||||
# This lets the Inspec.plugin(2,:your_plugin) work
|
||||
# This lets the Inspec.plugin(2,:your_plugin_type) 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
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
require 'inspec/base_cli'
|
||||
|
||||
module Inspec::Plugin::V2::PluginType
|
||||
class CliCommand < Inspec::BaseCLI
|
||||
# This class MUST inherit from Thor, which makes it a bit awkward to register the plugin subtype
|
||||
# Since we can't inherit from PluginBase, we use the two-arg form of register_plugin_type
|
||||
Inspec::Plugin::V2::PluginBase.register_plugin_type(:cli_command, self)
|
||||
|
||||
# Provide a description for the command group.
|
||||
def self.subcommand_desc(usage_msg, desc_msg)
|
||||
@usage_msg = usage_msg
|
||||
@desc_msg = desc_msg
|
||||
end
|
||||
|
||||
# Register the command group with Thor. This must be called on the implementation class AFTER
|
||||
# the the cli_command activator has been called
|
||||
def self.register_with_thor
|
||||
# Figure out my activator name (= subcommand group name)
|
||||
subcommand_name = Inspec::Plugin::V2::Registry.instance \
|
||||
.find_activators(plugin_type: :cli_command, implementation_class: self) \
|
||||
.first.activator_name.to_s
|
||||
|
||||
# Register with Thor
|
||||
Inspec::InspecCLI.register(self, subcommand_name, @usage_msg, @desc_msg, {})
|
||||
end
|
||||
end
|
||||
end
|
|
@ -48,10 +48,11 @@ module Inspec::Plugin::V2
|
|||
# @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
|
||||
# @param [Class] implementation_class Implementation class returned by an already-actived plugin type
|
||||
# @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|
|
||||
[:plugin_name, :plugin_type, :activator_name, :implementation_class].all? do |criteria|
|
||||
!filters.key?(criteria) || act[criteria] == filters[criteria]
|
||||
end
|
||||
end
|
||||
|
|
|
@ -47,3 +47,36 @@ describe 'plugin loader' do
|
|||
outcome.stdout.must_include('ZeroDivisionError', 'Include stacktrace in error with --debug')
|
||||
end
|
||||
end
|
||||
|
||||
#=========================================================================================#
|
||||
# CliCommand plugin type
|
||||
#=========================================================================================#
|
||||
describe 'cli command plugins' do
|
||||
include FunctionalHelper
|
||||
|
||||
it 'is able to respond to a plugin-based cli subcommand' do
|
||||
outcome = inspec_with_env('meaningoflife answer', INSPEC_CONFIG_DIR: File.join(config_dir_path, 'meaning_by_path'))
|
||||
outcome.stderr.wont_include 'Could not find command "meaningoflife"'
|
||||
outcome.stderr.must_equal ''
|
||||
outcome.stdout.must_equal ''
|
||||
outcome.exit_status.must_equal 42
|
||||
end
|
||||
|
||||
it 'is able to respond to [help subcommand] invocations' do
|
||||
outcome = inspec_with_env('help meaningoflife', INSPEC_CONFIG_DIR: File.join(config_dir_path, 'meaning_by_path'))
|
||||
outcome.exit_status.must_equal 0
|
||||
outcome.stderr.must_equal ''
|
||||
outcome.stdout.must_include 'inspec meaningoflife answer'
|
||||
# Full text:
|
||||
# 'Exits immediately with an exit code reflecting the answer to life the universe, and everything.'
|
||||
# but Thor will ellipsify based on the terminal width
|
||||
outcome.stdout.must_include 'Exits immediately'
|
||||
end
|
||||
|
||||
# This is an important test; usually CLI plugins are only activated when their name is present in ARGV
|
||||
it 'includes plugin-based cli commands in top-level help' do
|
||||
outcome = inspec_with_env('help', INSPEC_CONFIG_DIR: File.join(config_dir_path, 'meaning_by_path'))
|
||||
outcome.exit_status.must_equal 0
|
||||
outcome.stdout.must_include 'inspec meaningoflife'
|
||||
end
|
||||
end
|
|
@ -1,2 +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'
|
||||
load 'test/unit/mock/plugins/meaning_of_life_path_mode/inspec-meaning-of-life/plugin.rb'
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
module InspecPlugins
|
||||
module MeaningOfLife
|
||||
class CliCommand < Inspec.plugin(2, :cli_command)
|
||||
# Need to tell my superclass about my group description
|
||||
subcommand_desc 'meaningoflife answer', 'Get answers once and for all.'
|
||||
|
||||
# CLI test example
|
||||
desc 'answer', "Exits immediately with an exit code reflecting the answer to life the universe, and everything."
|
||||
def answer
|
||||
# exit immediately with code 42
|
||||
exit 42
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
|
@ -9,6 +9,12 @@ module InspecPlugins
|
|||
load 'test/unit/mock/plugins/meaning_of_life_path_mode/inspec-meaning-of-life/mock_plugin.rb'
|
||||
InspecPlugins::MeaningOfLife::MockPlugin
|
||||
end
|
||||
|
||||
cli_command 'meaningoflife' 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/cli_command.rb'
|
||||
InspecPlugins::MeaningOfLife::CliCommand
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
|
42
test/unit/plugin/v2/api_cli_test.rb
Normal file
42
test/unit/plugin/v2/api_cli_test.rb
Normal file
|
@ -0,0 +1,42 @@
|
|||
require 'minitest/autorun'
|
||||
require 'minitest/test'
|
||||
require 'byebug'
|
||||
|
||||
require_relative '../../../../lib/inspec/plugin/v2'
|
||||
|
||||
class CliCommandSuperclassTests < MiniTest::Test
|
||||
# you can call Inspec.plugin(2, :cli_command) and get the plugin base class
|
||||
def test_calling_Inspec_dot_plugin_with_cli_returns_the_cli_base_class
|
||||
klass = Inspec.plugin(2, :cli_command)
|
||||
assert_kind_of Class, klass
|
||||
assert_equal 'Inspec::Plugin::V2::PluginType::CliCommand', klass.name
|
||||
end
|
||||
|
||||
def test_plugin_type_base_classes_can_be_accessed_by_name
|
||||
klass = Inspec::Plugin::V2::PluginBase.base_class_for_type(:cli_command)
|
||||
assert_kind_of Class, klass
|
||||
assert_equal 'Inspec::Plugin::V2::PluginType::CliCommand', klass.name
|
||||
end
|
||||
|
||||
def test_plugin_type_registers_an_activation_dsl_method
|
||||
klass = Inspec::Plugin::V2::PluginBase
|
||||
assert_respond_to klass, :cli_command, 'Activation method for cli_command'
|
||||
end
|
||||
|
||||
def test_cli_plugin_type_inherits_from_thor
|
||||
klass = Inspec.plugin(2, :cli_command)
|
||||
assert_includes klass.ancestors, ::Thor, 'Cli Command plugin type should inherit from Thor'
|
||||
end
|
||||
end
|
||||
|
||||
class CliCommandPluginV2API < MiniTest::Test
|
||||
def test_cli_command_api_methods_present
|
||||
# instance methods
|
||||
[
|
||||
:invoke,
|
||||
].each do |method_name|
|
||||
klass = Inspec::Plugin::V2::PluginType::CliCommand
|
||||
assert klass.method_defined?(method_name), "CliCommand api instance method: #{method_name}"
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Add table
Reference in a new issue