From d24e0f0ec972474c13d3824ed78ba94565141ee7 Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Thu, 16 Aug 2018 20:22:28 -0400 Subject: [PATCH] Plugins V2 API: CLI Command Plugin Type, Again (#3296) Plugins V2 API: CLI Command Plugin Type Signed-off-by: Clinton Wolfe --- docs/dev/plugins.md | 182 ++++++++++++++++-- lib/inspec/cli.rb | 1 + lib/inspec/plugin/v2/loader.rb | 13 ++ lib/inspec/plugin/v2/plugin_base.rb | 8 +- lib/inspec/plugin/v2/plugin_types/cli.rb | 27 +++ lib/inspec/plugin/v2/registry.rb | 3 +- test/functional/plugins_test.rb | 33 ++++ .../inspec-meaning-of-life.rb | 2 +- .../inspec-meaning-of-life/cli_command.rb | 16 ++ .../inspec-meaning-of-life/plugin.rb | 6 + test/unit/plugin/v2/api_cli_test.rb | 42 ++++ 11 files changed, 316 insertions(+), 17 deletions(-) create mode 100644 test/unit/mock/plugins/meaning_of_life_path_mode/inspec-meaning-of-life/cli_command.rb create mode 100644 test/unit/plugin/v2/api_cli_test.rb diff --git a/docs/dev/plugins.md b/docs/dev/plugins.md index 2045f46aa..3d7953637 100644 --- a/docs/dev/plugins.md +++ b/docs/dev/plugins.md @@ -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 +``` \ No newline at end of file diff --git a/lib/inspec/cli.rb b/lib/inspec/cli.rb index 737cdbf5b..da58b6def 100644 --- a/lib/inspec/cli.rb +++ b/lib/inspec/cli.rb @@ -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 diff --git a/lib/inspec/plugin/v2/loader.rb b/lib/inspec/plugin/v2/loader.rb index 5ecb8d0d2..7a182cada 100644 --- a/lib/inspec/plugin/v2/loader.rb +++ b/lib/inspec/plugin/v2/loader.rb @@ -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) diff --git a/lib/inspec/plugin/v2/plugin_base.rb b/lib/inspec/plugin/v2/plugin_base.rb index 24c53b836..ba8a15f76 100644 --- a/lib/inspec/plugin/v2/plugin_base.rb +++ b/lib/inspec/plugin/v2/plugin_base.rb @@ -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 diff --git a/lib/inspec/plugin/v2/plugin_types/cli.rb b/lib/inspec/plugin/v2/plugin_types/cli.rb index e69de29bb..042d82a01 100644 --- a/lib/inspec/plugin/v2/plugin_types/cli.rb +++ b/lib/inspec/plugin/v2/plugin_types/cli.rb @@ -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 diff --git a/lib/inspec/plugin/v2/registry.rb b/lib/inspec/plugin/v2/registry.rb index 25525dd5f..57da63cdd 100644 --- a/lib/inspec/plugin/v2/registry.rb +++ b/lib/inspec/plugin/v2/registry.rb @@ -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 diff --git a/test/functional/plugins_test.rb b/test/functional/plugins_test.rb index 5ec24f590..d018e4528 100644 --- a/test/functional/plugins_test.rb +++ b/test/functional/plugins_test.rb @@ -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 \ No newline at end of file diff --git a/test/unit/mock/plugins/meaning_of_life_path_mode/inspec-meaning-of-life.rb b/test/unit/mock/plugins/meaning_of_life_path_mode/inspec-meaning-of-life.rb index 643adfc5e..9af031f05 100644 --- a/test/unit/mock/plugins/meaning_of_life_path_mode/inspec-meaning-of-life.rb +++ b/test/unit/mock/plugins/meaning_of_life_path_mode/inspec-meaning-of-life.rb @@ -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' \ No newline at end of file +load 'test/unit/mock/plugins/meaning_of_life_path_mode/inspec-meaning-of-life/plugin.rb' diff --git a/test/unit/mock/plugins/meaning_of_life_path_mode/inspec-meaning-of-life/cli_command.rb b/test/unit/mock/plugins/meaning_of_life_path_mode/inspec-meaning-of-life/cli_command.rb new file mode 100644 index 000000000..2712dbd1f --- /dev/null +++ b/test/unit/mock/plugins/meaning_of_life_path_mode/inspec-meaning-of-life/cli_command.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 \ No newline at end of file diff --git a/test/unit/mock/plugins/meaning_of_life_path_mode/inspec-meaning-of-life/plugin.rb b/test/unit/mock/plugins/meaning_of_life_path_mode/inspec-meaning-of-life/plugin.rb index b012fda37..2a484440a 100644 --- a/test/unit/mock/plugins/meaning_of_life_path_mode/inspec-meaning-of-life/plugin.rb +++ b/test/unit/mock/plugins/meaning_of_life_path_mode/inspec-meaning-of-life/plugin.rb @@ -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 diff --git a/test/unit/plugin/v2/api_cli_test.rb b/test/unit/plugin/v2/api_cli_test.rb new file mode 100644 index 000000000..d4ba66450 --- /dev/null +++ b/test/unit/plugin/v2/api_cli_test.rb @@ -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