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:
Clinton Wolfe 2018-08-16 20:22:28 -04:00 committed by Jared Quick
parent 811318f2f8
commit d24e0f0ec9
11 changed files with 316 additions and 17 deletions

View file

@ -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
```

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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'

View file

@ -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

View file

@ -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

View 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