From fefa6c2ecd6d22ab6f23bb4941fb3a308b2afc99 Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Thu, 29 Nov 2018 14:14:06 -0500 Subject: [PATCH] Plugin Type: DSLs (#3557) This PR adds 5 closely related plugin types, which allow a plugin to implement new DSL methods / keywords. The mechanism to activate the plugins are all very similar - basically, in a particular location in the code, `method_missing` is implemented, and is used to activate the particular type of DSL being requested. 4 of the DSL plugin types relate to code that could appear in a profile control file. * outer_profile_dsl plugins allow you to extend the code in profile Ruby files that appear outside `control` or `describe` blocks. * control_dsl plugins allow you to extend the code within `control` blocks. * describe_dsl plugins allow you to extend the code within `describe` blocks. * test_dsl plugins allow you to extend the code within `it`/`its` blocks. Finally, the `resource_dsl` plugin allows you to extend the code used within custom resources. Basic unit tests are provided to prove that the plugin types are properly defined. A simple plugin fixture defining DSL hooks (based on favorite foods) is included, and is exercised through a set of functional tests. The plugin developer docs are updated to describe the 5 DSLs. *Note*: Implementing a plugin using any of the DSL plugin types is experimental. The contexts that are exposed to the DSL methods are private and poorly documented. The InSpec project does not claim the APIs used by these plugin types are covered by SemVer. Plugin authors are encouraged to pin tightly to the `inspec` gem in their gemspecs. Motivation for this plugin comes from the desire to allow passionate community members to implement things like "2 out of 3" tests, example groups, improved serverspec compatibility, "they/their" and other "fluency" changes, as well as make it possible for future work by the InSpec team to be implemented as a core plugin, rather than a direct change to the main codebase. --- docs/dev/plugins.md | 185 ++++++++++++++++++ docs/plugins.md | 3 +- lib/inspec/control_eval_context.rb | 27 ++- lib/inspec/dsl.rb | 23 +++ lib/inspec/plugin/v1/plugin_types/resource.rb | 28 +++ lib/inspec/plugin/v2/loader.rb | 17 +- lib/inspec/plugin/v2/plugin_types/dsl.rb | 11 ++ lib/inspec/plugin/v2/registry.rb | 15 ++ lib/inspec/rspec_extensions.rb | 76 ++++++- test/functional/helper.rb | 1 - test/functional/plugins_test.rb | 167 ++++++++++++++++ .../inspec-dsl-test/lib/inspec-dsl-test.rb | 5 + .../lib/inspec-dsl-test/control_dsl.rb | 12 ++ .../lib/inspec-dsl-test/describe_dsl.rb | 15 ++ .../lib/inspec-dsl-test/outer_profile_dsl.rb | 14 ++ .../lib/inspec-dsl-test/plugin.rb | 35 ++++ .../lib/inspec-dsl-test/resource_dsl.rb | 14 ++ .../lib/inspec-dsl-test/test_dsl.rb | 12 ++ .../lib/inspec-dsl-test/version.rb | 5 + .../dsl_plugins/controls/control_dsl.rb | 15 ++ .../dsl_plugins/controls/describe_dsl.rb | 18 ++ .../dsl_plugins/controls/outer_profile_dsl.rb | 19 ++ .../dsl_plugins/controls/resource_dsl.rb | 25 +++ .../profiles/dsl_plugins/controls/test_dsl.rb | 20 ++ .../unit/mock/profiles/dsl_plugins/inspec.yml | 8 + .../libraries/favorite_berry_resource.rb | 30 +++ test/unit/plugin/v2/api_dsl_test.rb | 45 +++++ test/unit/plugin/v2/loader_test.rb | 4 +- 28 files changed, 826 insertions(+), 23 deletions(-) create mode 100644 lib/inspec/plugin/v2/plugin_types/dsl.rb create mode 100644 test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test.rb create mode 100644 test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test/control_dsl.rb create mode 100644 test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test/describe_dsl.rb create mode 100644 test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test/outer_profile_dsl.rb create mode 100644 test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test/plugin.rb create mode 100644 test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test/resource_dsl.rb create mode 100644 test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test/test_dsl.rb create mode 100644 test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test/version.rb create mode 100644 test/unit/mock/profiles/dsl_plugins/controls/control_dsl.rb create mode 100644 test/unit/mock/profiles/dsl_plugins/controls/describe_dsl.rb create mode 100644 test/unit/mock/profiles/dsl_plugins/controls/outer_profile_dsl.rb create mode 100644 test/unit/mock/profiles/dsl_plugins/controls/resource_dsl.rb create mode 100644 test/unit/mock/profiles/dsl_plugins/controls/test_dsl.rb create mode 100644 test/unit/mock/profiles/dsl_plugins/inspec.yml create mode 100644 test/unit/mock/profiles/dsl_plugins/libraries/favorite_berry_resource.rb create mode 100644 test/unit/plugin/v2/api_dsl_test.rb diff --git a/docs/dev/plugins.md b/docs/dev/plugins.md index 9ade47177..568e8e3bd 100644 --- a/docs/dev/plugins.md +++ b/docs/dev/plugins.md @@ -321,3 +321,188 @@ no_command do end end ``` + +## Implementing DSL Plugins + +A DSL is a _domain specific language_, or a set of keywords you can use to write InSpec profiles and resources more fluently. + +InSpec offers several DSLs: + +* The Profile DSL, which is the set of keywords you use when writing profiles. The Profile DSL is internally divided into: + * The Outer Profile DSL: those keywords which may appear in a Profile `controls/my-controls.rb` outside of a `control` or `describe` block + * The Control DSL: those keywords which may appear in `control` block + * The Describe DSL: those keywords which may appear within a `describe` block + * The Test DSL: those keywords available within an `it`/`its` block +* The Resource DSL: those keywords which may be used when authoring a resource + +Correspondingly, there are 4 plugin types in play here: `outer_profile_dsl`, `control_dsl`, `describe_dsl`, `test_dsl`, and `resource_dsl`. + +DSL plugins let you alter the InSpec profile authoring experience in a fundamental way. For example, if you wish InSpec had a way of expressing that some minimum of a set of tests must pass, but you don't care which, you could implement a `control_dsl` plugin named `threshold`: + +```ruby +# in a hypothetical control file + +# This control will pass if at least 2 +# out of the describe blocks pass +control 'Like Meatloaf Sings' do + threshold(2) do + describe 'I want you' do + it { should be_true } + end + + describe 'I need you' do + it { should be_true } + end + + describe 'I love you' do + it { should be_true } + end + end +end +``` + +### Activation Discipline For DSL Plugins + +As DSL keywords are actually method calls, the activation system for the four DSL types is handled by `method_missing`. For example, if you have registered a `control_dsl` activation hook named `threshold`, when InSpec evaluates the code above and encounters the unknown method `threshold`, InSpec will check for a `control_dsl` hook with that name, and if found, activate the hook, and then include the resulting module into that and all future controls. Once the module is loaded and included, future calls bypass the activation and loading mechanism entirely (because the `threshold` method is now defined, we never hit the `method_missing` that watches for activations). + +The Outer Profile DSL, Control DSL, Describe DSL, Test DSL, and Resource DSL plugin types all have the same basic mechanism; only the scope of their activation varies. + +### Defining DSL Plugin Activation Hooks + +In your `plugin.rb`, include one or more `outer_profile_dsl`, `control_dsl`, `describe_dsl`, or `resource_dsl` activation blocks. A DSL activation block *must* do two things (though it may do more): + + * Return a Module that will be used as a mixin to the file, control, describe block, or resource + * Require any files needed to support returning the implementation module. It's important to require any support files in the activation block, not in the plugin definition; this allows InSpec to only load files as they are needed. + +Continuing the above example, one would declare the `threshold` Control DSL activation hook as follows: + +```ruby +# in plugin.rb +module InspecPlugins::ThresholdDSL + class Plugin < Inspec.plugin(2) + plugin_name :'inspec-dsl-threshold' + + control_dsl :threshold do + require 'inspec-dsl-threshold/control_dsl' + # most plugins expect you to return a class name; + # but DSL plugins must return a Module, because it + # will be used as a mixin. + InspecPlugins::ThresholdDSL::ThresholdControlDSL + end + + end +end +``` + +### Implementing DSL Methods + +Because each DSL plugin type is loaded into a specific context, each method defined in the mixin module you provide will have a specific parent class and state. + +*Note*: these areas are deep within the internals of InSpec and RSpec. Documentation and stability of these interfaces will vary. +It is recommended to pin your dependency on `inspec` rather tightly, so you can test for compatibility issues prior to your users. +The InSpec project does not consider the internal interfaces exposed to the DSL plugins to be part of the public interface, and thus may introduce breaking changes at anytime. In other words, SemVer doesn't apply here, and you should likely use an exact pin. + +#### Outer Profile DSL Context + +When your mixin method is called, `self` will be an instance of an anonymous class representing the profile context being executed; each profile context gets its own anonymous class. No inheritance tree is provided; all meaningful functionality arrives through other DSL methods included. + +One useful thing you can do is create macros for generating controls: the `control` DSL keyword is available to you, so you can call it as you see fit to create new controls. + +#### Control DSL Context + +When your mixin method is called, `self` will be an instance of an anonymous class representing the control being executed; each control gets its own anonymous class. The parent class of the anonymous class will be [Inspec::Rule](https://github.com/inspec/inspec/blob/master/lib/inspec/rule.rb), which is the internal name of a Control. Please refer to the source for details on methods and instance variables. + +#### Describe DSL Context + +Describe DSL mixin methods will be attached as *class* methods to [RSpec::Core::ExampleGroup](https://github.com/rspec/rspec-core/blob/master/lib/rspec/core/example_group.rb). Internally, 'describe' blocks are subclasses of the ExampleGroup class. Please see the source of ExampleGroup for details about how describe blocks are evaluated. + +Within your mixin method, you have access the methods RSpec uses to manage an ExampleGroup. For example, `examples` returns an array of tests (`it`/`its` blocks) that have been encountered in the describe block prior to the invocation of your method; and `metadata` returns a hash of information about the describe block, including description and source code location. + +#### Test DSL Context + +Test DSL mixin methods will be attached as *instance* methods to [RSpec::Core::ExampleGroup](https://github.com/rspec/rspec-core/blob/master/lib/rspec/core/example_group.rb). Internally, `it`/`its` blocks are evaluated in the context of an instance which is a subclass of the ExampleGroup class. Please see the source of ExampleGroup for further details. + +These blocks are called Examples in RSpec terminology. InSpec treats Examples as tests, and sends tests and controls to the reporter engine; note that describe block are effectively ignored. + +Within your mixin method, you have access the methods RSpec uses to manage an Example. You have access to the testing predicates (such as `should`), but also all InSpec resources are available by name. Some useful class methods include `self.class.example_group`, which returns the example group are a member of; and `self.class.metadata` returns a hash of information about the test block, including description and source code location. + +#### Resource DSL + +Within a Resource DSL method, `self` will be the Class of a Resource that is currently being defined. Your superclass will be whatever was returned by Inspec.resource(API_VERSION), which will typically be Inspec::Resource. + +Resource DSL methods are especially useful for defining macros: adding properties and matchers to a resource. + +### Implementation Module Layout Notes + +#### Implementing multiple DSL methods of the same type in one Module + +You may implement as many DSL methods as you see fit. You may choose to be fine-grained, and load each individually from separate modules contained in separate files. + +If you believe that using one of your suite of DSL methods implies that the user would be likely to use all of your suite, you may choose to implement them all in one mixin. This saves on loading and activations. + +That might look like: + +```ruby +# in plugin.rb +module InspecPlugins::ColorDSL + class Plugin < Inspec.plugin(2) + plugin_name :'inspec-dsl-colors' + + control_dsl :red do + require 'inspec-dsl-threshold/roygbiv' + InspecPlugins::ColorDSL::RoyGBiv + end + + control_dsl :orange do + require 'inspec-dsl-threshold/roygbiv' + InspecPlugins::ColorDSL::RoyGBiv + end + + # etc... ... and yes, you could do that in a loop + end +end +``` + +Now, when a user says `red` or `orange`, the entire suite of DSL methods will be loaded and included. + +#### Implementing multiple DSL methods of the different types in one Module + +For the brave, one may also choose to use the same implementation mixin with different types of activation hook. This has serious implications for the code inside your DSL methods; depending on which context you are in, your class hierarchy (and thus instance methods and variables) may change dramatically. + +For DSL plugins that are fairly simple - perhaps adding annotations or having a simple runtime side-effect - this may be a wise choice to avoid duplicating code. However, DSL methods that are very interested in the state of their context will be obliged to rely on a fair bit of conditionals and introspection. + +That might look like: + +```ruby +# in plugin.rb +module InspecPlugins::ColorDSL + class Plugin < Inspec.plugin(2) + plugin_name :'inspec-dsl-colors' + + # Install the `red` DSL method at every available place within profiles + # (with the same implementation!) + outer_profile_dsl :red do + require 'inspec-dsl-threshold/red' + InspecPlugins::ColorDSL::Red + end + + control_dsl :red do + require 'inspec-dsl-threshold/red' + InspecPlugins::ColorDSL::Red + end + + describe_dsl :red do + require 'inspec-dsl-threshold/red' + InspecPlugins::ColorDSL::Red + end + + test_dsl :red do + require 'inspec-dsl-threshold/red' + InspecPlugins::ColorDSL::Red + end + + end +end +``` + +This approach may make sense among the four Profile DSLs; however the Resource DSL is quite different, and is unlikely to respond well to such an approach. \ No newline at end of file diff --git a/docs/plugins.md b/docs/plugins.md index a149f3d08..e1b271105 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -18,11 +18,12 @@ Currently, each plugin can offer one or more of these capabilities: * define a new command-line-interface (CLI) command suite (`inspec` plugins) * connectivity to new types of hosts or cloud providers (`train` plugins) + * DSL extensions at the file, control, describe block, or test level +* DSL extensions for custom resources Future work might include new capability types, such as: * reporters (output generators) - * DSL extensions at the file, control, or test level * attribute fetchers to allow reading InSpec attributes from new sources (for example, a remote encrypted key-value store) ## How do I find out which plugins are available? diff --git a/lib/inspec/control_eval_context.rb b/lib/inspec/control_eval_context.rb index ecdffe4ed..51a0ecbbc 100644 --- a/lib/inspec/control_eval_context.rb +++ b/lib/inspec/control_eval_context.rb @@ -29,6 +29,32 @@ module Inspec define_method :attribute do |name| Inspec::AttributeRegistry.find_attribute(name, profile_id).value end + + # Support for Control DSL plugins. + # This is called when an unknown method is encountered + # within a control block. + def method_missing(method_name, *arguments, &block) + # Check to see if there is a control_dsl plugin activator hook with the method name + registry = Inspec::Plugin::V2::Registry.instance + hook = registry.find_activators(plugin_type: :control_dsl, activator_name: method_name).first + if hook + # OK, load the hook if it hasn't been already. We'll then know a module, + # which we can then inject into the context + registry.activate(:control_dsl, method_name) unless hook.activated? + # Inject the module's methods into the context. + # implementation_class is the field name, but this is actually a module. + self.class.include(hook.implementation_class) + # Now that the module is loaded, it defined one or more methods + # (presumably the one we were looking for.) + # We still haven't called it, so do so now. + send(method_name, *arguments, &block) + else + # If we couldn't find a plugin to match, maybe something up above has it, + # or maybe it is just a unknown method error. + super + end + end + end end @@ -44,7 +70,6 @@ module Inspec profile_context_owner = profile_context profile_id = profile_context.profile_id rule_class = rule_context(resources_dsl, profile_id) - Class.new do # rubocop:disable Metrics/BlockLength include Inspec::DSL include Inspec::DSL::RequireOverride diff --git a/lib/inspec/dsl.rb b/lib/inspec/dsl.rb index c6c81189b..1eecee12c 100644 --- a/lib/inspec/dsl.rb +++ b/lib/inspec/dsl.rb @@ -27,6 +27,29 @@ module Inspec::DSL add_resource(target_name, res) end + # Support for Outer Profile DSL plugins + # This is called when an unknown method is encountered + # "bare" in a control file - outside of a control or describe block. + def method_missing(method_name, *arguments, &block) + # Check to see if there is a outer_profile_dsl plugin activator hook with the method name + registry = Inspec::Plugin::V2::Registry.instance + hook = registry.find_activators(plugin_type: :outer_profile_dsl, activator_name: method_name).first + if hook + # OK, load the hook if it hasn't been already. We'll then know a module, + # which we can then inject into the context + registry.activate(:outer_profile_dsl, method_name) unless hook.activated? + # Inject the module's methods into the context + # implementation_class is the field name, but this is actually a module. + self.class.include(hook.implementation_class) + # Now that the module is loaded, it defined one or more methods + # (presumably the one we were looking for.) + # We still haven't called it, so do so now. + send(method_name, *arguments, &block) + else + super + end + end + def self.load_spec_files_for_profile(bind_context, opts, &block) dependencies = opts[:dependencies] profile_id = opts[:profile_id] diff --git a/lib/inspec/plugin/v1/plugin_types/resource.rb b/lib/inspec/plugin/v1/plugin_types/resource.rb index 6113cbf35..f2520a96e 100644 --- a/lib/inspec/plugin/v1/plugin_types/resource.rb +++ b/lib/inspec/plugin/v1/plugin_types/resource.rb @@ -39,6 +39,34 @@ module Inspec __resource_registry[@name].example(example) end + # Support for Resource DSL plugins. + # This is called when an unknown method is encountered + # within a resource class definition. + # Even tho this is defined as an instance method, it gets added to + # Inspec::Plugins::Resource via `extend`, so this is actually a class defintion. + def method_missing(method_name, *arguments, &block) + require 'inspec/plugin/v2' + # Check to see if there is a resource_dsl plugin activator hook with the method name + registry = Inspec::Plugin::V2::Registry.instance + hook = registry.find_activators(plugin_type: :resource_dsl, activator_name: method_name).first + if hook + # OK, load the hook if it hasn't been already. We'll then know a module, + # which we can then inject into the resource + registry.activate(:resource_dsl, method_name) unless hook.activated? + # Inject the module's methods into the resource as class methods. + # implementation_class is the field name, but this is actually a module. + extend(hook.implementation_class) + # Now that the module is loaded, it defined one or more methods + # (presumably the one we were looking for.) + # We still haven't called it, so do so now. + send(method_name, *arguments, &block) + else + # If we couldn't find a plugin to match, maybe something up above has it, + # or maybe it is just a unknown method error. + super + end + end + def __resource_registry Inspec::Resource.registry end diff --git a/lib/inspec/plugin/v2/loader.rb b/lib/inspec/plugin/v2/loader.rb index 91c618f37..939c0a79c 100644 --- a/lib/inspec/plugin/v2/loader.rb +++ b/lib/inspec/plugin/v2/loader.rb @@ -104,27 +104,12 @@ module Inspec::Plugin::V2 # OK, activate. if activate_me - activate(:cli_command, act.activator_name) + registry.activate(:cli_command, act.activator_name) act.implementation_class.register_with_thor end end end - def activate(plugin_type, hook_name) - activator = registry.find_activators(plugin_type: plugin_type, activator_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 - def plugin_gem_path self.class.plugin_gem_path end diff --git a/lib/inspec/plugin/v2/plugin_types/dsl.rb b/lib/inspec/plugin/v2/plugin_types/dsl.rb new file mode 100644 index 000000000..c0b7d7aac --- /dev/null +++ b/lib/inspec/plugin/v2/plugin_types/dsl.rb @@ -0,0 +1,11 @@ +# All DSL plugin types are defined here. + +module Inspec::Plugin::V2::PluginType + class Dsl < Inspec::Plugin::V2::PluginBase + register_plugin_type(:outer_profile_dsl) + register_plugin_type(:control_dsl) + register_plugin_type(:describe_dsl) + register_plugin_type(:test_dsl) + register_plugin_type(:resource_dsl) + end +end diff --git a/lib/inspec/plugin/v2/registry.rb b/lib/inspec/plugin/v2/registry.rb index 0bf07970d..f9fd30a60 100644 --- a/lib/inspec/plugin/v2/registry.rb +++ b/lib/inspec/plugin/v2/registry.rb @@ -67,6 +67,21 @@ module Inspec::Plugin::V2 end end + def activate(plugin_type, hook_name) + activator = find_activators(plugin_type: plugin_type, activator_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 + def register(name, status) if known_plugin? name Inspec::Log.debug "PluginLoader: refusing to re-register plugin '#{name}': an existing plugin with that name was loaded via #{registry[name].installation_type}-loading from #{registry[name].entry_point}" diff --git a/lib/inspec/rspec_extensions.rb b/lib/inspec/rspec_extensions.rb index 5199dfdc2..2f66ebc56 100644 --- a/lib/inspec/rspec_extensions.rb +++ b/lib/inspec/rspec_extensions.rb @@ -1,12 +1,84 @@ require 'inspec/attribute_registry' +require 'inspec/plugin/v2' require 'rspec/core/example_group' -# This file allows you to add ExampleGroups to be used in rspec tests -# +# Any additions to RSpec::Core::ExampleGroup (the RSpec class behind describe blocks) should go here. + +module Inspec + # This module exists to intercept the method_missing *class* method on RSpec::Core::ExampleGroup + # and is part of support for DSL plugintypes + module DescribeDslLazyLoader + # Support for Describe DSL plugins + def method_missing(method_name, *arguments, &block) + # Check to see if there is a describe_dsl plugin activator hook with the method name + registry = Inspec::Plugin::V2::Registry.instance + hook = registry.find_activators(plugin_type: :describe_dsl, activator_name: method_name).first + + if hook + # OK, load the hook if it hasn't been already. We'll then know a module, + # which we can then inject into the context + registry.activate(:describe_dsl, method_name) unless hook.activated? + + # Inject the module's methods into the example group contexts. + # implementation_class is the field name, but this is actually a module. + # RSpec works by having these helper methods defined as class methods + # (see the definition of `let` as an example) + # So, we use extend to inject the new DSL methods. + RSpec::Core::ExampleGroup.extend(hook.implementation_class) + + # We still haven't called the method we were looking for, so do so now. + send(method_name, *arguments, &block) + else + super + end + end + end + + # This module exists to intercept the method_missing *instance* method on RSpec::Core::ExampleGroup + # and is part of support for DSL plugintypes + module TestDslLazyLoader + # Support for test DSL plugins + def method_missing(method_name, *arguments, &block) + # Check to see if there is a test_dsl plugin activator hook with the method name + registry = Inspec::Plugin::V2::Registry.instance + hook = registry.find_activators(plugin_type: :test_dsl, activator_name: method_name).first + + if hook + # OK, load the hook if it hasn't been already. We'll then know a module, + # which we can then inject into the context + registry.activate(:test_dsl, method_name) unless hook.activated? + + # Inject the module's methods into the example group contexts. + # implementation_class is the field name, but this is actually a module. + # RSpec works by having these helper methods defined as instance methods. + # So, we use include to inject the new DSL methods. + RSpec::Core::ExampleGroup.include(hook.implementation_class) + + # We still haven't called the method we were looking for, so do so now. + send(method_name, *arguments, &block) + else + super + end + end + end +end + class RSpec::Core::ExampleGroup # This DSL method allows us to access the values of attributes within InSpec tests def attribute(name) Inspec::AttributeRegistry.find_attribute(name, self.class.metadata[:profile_id]).value end define_example_method :attribute + + # Here, we have to ensure our method_missing gets called prior + # to RSpec::Core::ExampleGroup.method_missing (the class method). + # So, we use prepend. + # Because it is a class method we're attempting to prepend, we must + # prepend against the singleton class. + singleton_class.prepend Inspec::DescribeDslLazyLoader + + # Here, we have to ensure our method_missing gets called prior + # to RSpec::Core::ExampleGroup#method_missing (the instance method). + # So, we use prepend. + prepend Inspec::TestDslLazyLoader end diff --git a/test/functional/helper.rb b/test/functional/helper.rb index 192d0c078..889cf1459 100644 --- a/test/functional/helper.rb +++ b/test/functional/helper.rb @@ -154,7 +154,6 @@ module FunctionalHelper 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 diff --git a/test/functional/plugins_test.rb b/test/functional/plugins_test.rb index 5681074b8..020312395 100644 --- a/test/functional/plugins_test.rb +++ b/test/functional/plugins_test.rb @@ -2,6 +2,50 @@ require 'functional/helper' +#=========================================================================================# +# Support +#=========================================================================================# +module PluginFunctionalHelper + include FunctionalHelper + + def run_inspec_with_plugin(command, opts) + pre = Proc.new do |tmp_dir| + content = JSON.generate(__make_plugin_file_data_structure_with_path(opts[:plugin_path])) + File.write(File.join(tmp_dir, 'plugins.json'), content) + end + + opts.merge!({ + pre_run: pre, + tmpdir: true, + json: true, + env: { + "INSPEC_CONFIG_DIR" => '.' # We're in tmpdir + } + }) + run_inspec_process(command, opts) + end + + def __make_plugin_file_data_structure_with_path(path) + # TODO: dry this up, refs #3350 + plugin_name = File.basename(path, '.rb') + data = __make_empty_plugin_file_data_structure + data['plugins'] << { + 'name' => plugin_name, + 'installation_type' => 'path', + 'installation_path' => path, + } + data + end + + def __make_empty_plugin_file_data_structure + # TODO: dry this up, refs #3350 + { + 'plugins_config_version' => '1.0.0', + 'plugins' => [], + } + end +end + #=========================================================================================# # Loader Errors #=========================================================================================# @@ -81,6 +125,129 @@ describe 'plugin cli usage message integration' do end end +#=========================================================================================# +# DSL Plugin Support +#=========================================================================================# + +describe 'DSL plugin types support' do + include PluginFunctionalHelper + + let(:fixture_path) { File.join(profile_path, 'dsl_plugins', 'controls', profile_file)} + let(:dsl_plugin_path) { File.join(mock_path, 'plugins', 'inspec-dsl-test', 'lib', 'inspec-dsl-test.rb')} + let(:run_result) { run_inspec_with_plugin("exec #{fixture_path}", plugin_path: dsl_plugin_path) } + let(:json_result) { run_result.payload.json } + + describe 'outer profile dsl plugin type support' do + let(:profile_file) { 'outer_profile_dsl.rb' } + it 'works correctly with outer_profile dsl extensions' do + run_result.stderr.must_equal '' + + # The outer_profile_dsl.rb file has control-01, then a call to favorite_grain + # (which generates a control), then control-03. + # If the plugin exploded, we'd see control-01 but not control-03 + controls = json_result['profiles'][0]['controls'] + controls.count.must_equal 3 + + # We expect the second controls id to be 'sorghum' + # (this is the functionality of the outer_profile_dsl we installed) + generated_control = json_result['profiles'][0]['controls'][1] + generated_control['id'].must_equal 'sorghum' + generated_control['results'][0]['status'].must_equal 'passed' + end + end + + describe 'control dsl plugin type support' do + + let(:profile_file) { 'control_dsl.rb' } + it 'works correctly with control dsl extensions' do + run_result.stderr.must_equal '' + + # The control_dsl.rb file has one control, with a describe-01, then a call to favorite_fruit, then describe-02 + # If the plugin exploded, we'd see describe-01 but not describe-02 + results = json_result['profiles'][0]['controls'][0]['results'] + results.count.must_equal 2 + + # We expect the descriptions to include that the favorite fruit is banana + # (this is the functionality of the control_dsl we installed) + first_description_section = json_result['profiles'][0]['controls'][0]['descriptions'].first + first_description_section.wont_be_nil + first_description_section['label'].must_equal 'favorite_fruit' + first_description_section['data'].must_equal 'Banana' + end + end + + describe 'describe dsl plugin type support' do + let(:profile_file) { 'describe_dsl.rb' } + it 'works correctly with describe dsl extensions' do + run_result.stderr.must_equal '' + + # The describe_dsl.rb file has one control, with + # describe-01, describe-02 which contains a call to favorite_vegetable, then describe-03 + # If the plugin exploded, we'd see describe-01 but not describe-02 + results = json_result['profiles'][0]['controls'][0]['results'] + results.count.must_equal 3 + + # We expect the description of describe-02 to include the word aubergine + # (this is the functionality of the describe_dsl we installed) + second_result = json_result['profiles'][0]['controls'][0]['results'][1] + second_result.wont_be_nil + second_result['code_desc'].must_include 'aubergine' + end + end + + describe 'test dsl plugin type support' do + let(:profile_file) { 'test_dsl.rb' } + it 'works correctly with test dsl extensions' do + run_result.stderr.must_equal '' + + # The test_dsl.rb file has one control, with + # describe-01, describe-02 which contains a call to favorite_legume, then describe-03 + # If the plugin exploded, we'd see describe-01 but not describe-02 + results = json_result['profiles'][0]['controls'][0]['results'] + results.count.must_equal 3 + + # I spent a while trying to find a way to get the test to alter its name; + # that won't work for various setup reasons. + # So, it just throws an exception with the word 'edemame' in it. + second_result = json_result['profiles'][0]['controls'][0]['results'][1] + second_result.wont_be_nil + second_result['status'].must_equal 'failed' + second_result['message'].must_include 'edemame' + end + end + + describe 'resource dsl plugin type support' do + let(:profile_file) { 'unused' } + it 'works correctly with test dsl extensions' do + # We have to build a custom command line - need to load the whole profile, + # so the libraries get loaded. + cmd = 'exec ' + cmd += File.join(profile_path, 'dsl_plugins') + cmd += ' --controls=/^rdsl-control/ ' + run_result = run_inspec_with_plugin(cmd, plugin_path: dsl_plugin_path) + run_result.stderr.must_equal '' + + # We should have three controls; 01 and 03 just do a string match. + # 02 uses the custom resource, which relies on calls to the resource DSL. + # If the plugin exploded, we'd see rdsl-control-01 but not rdsl-control-02 + json_result = run_result.payload.json + results = json_result['profiles'][0]['controls'] + results.count.must_equal 3 + + # Control 2 has 2 describes; one uses a simple explicit matcher, + # while the second uses a matcher defined via a macro provided by the resource DSL. + control2_results = results[1]['results'] + control2_results[0]['status'].must_equal 'passed' + control2_results[0]['code_desc'].must_include 'favorite_berry' + control2_results[0]['code_desc'].must_include 'blendable' + + control2_results[1]['status'].must_equal 'passed' + control2_results[1]['code_desc'].must_include 'favorite_berry' + control2_results[1]['code_desc'].must_include 'have drupals' + end + end +end + #=========================================================================================# # Train Plugin Support #=========================================================================================# diff --git a/test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test.rb b/test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test.rb new file mode 100644 index 000000000..6b2c41322 --- /dev/null +++ b/test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test.rb @@ -0,0 +1,5 @@ +lib = File.expand_path("../../lib", __FILE__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) + +require_relative 'inspec-dsl-test/version' +require_relative 'inspec-dsl-test/plugin' diff --git a/test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test/control_dsl.rb b/test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test/control_dsl.rb new file mode 100644 index 000000000..4d81ac6e0 --- /dev/null +++ b/test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test/control_dsl.rb @@ -0,0 +1,12 @@ +module InspecPlugins + module DslTest + module ControlDslFavoriteFruit + def favorite_fruit(fruit) + # Here, self is an instance of an anonymous class, derived from Inspec::Rule + + # Our behavior is to add our favorite fruit to the descriptions of the control. + @descriptions[:favorite_fruit] = fruit + end + end + end +end diff --git a/test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test/describe_dsl.rb b/test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test/describe_dsl.rb new file mode 100644 index 000000000..2b1a9ec65 --- /dev/null +++ b/test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test/describe_dsl.rb @@ -0,0 +1,15 @@ +module InspecPlugins + module DslTest + module DescribeDslFavoriteVegetable + def favorite_vegetable(veggie) + + # Inspec ignores example groups. It only cares about examples. + # So, to have a visible effect in the reporter output, alter the examples. + examples.each do |example| + example.metadata[:full_description] += veggie + end + + end + end + end +end diff --git a/test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test/outer_profile_dsl.rb b/test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test/outer_profile_dsl.rb new file mode 100644 index 000000000..c37e4ac07 --- /dev/null +++ b/test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test/outer_profile_dsl.rb @@ -0,0 +1,14 @@ +module InspecPlugins + module DslTest + module OuterProfileDslFavoriteGrain + def favorite_grain(grain) + # Inject a new control + control(grain) do + describe(grain) do + it { should eq grain } + end + end + end + end + end +end diff --git a/test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test/plugin.rb b/test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test/plugin.rb new file mode 100644 index 000000000..f766823dd --- /dev/null +++ b/test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test/plugin.rb @@ -0,0 +1,35 @@ +require 'inspec/plugin/v2' + +module InspecPlugins + module DslTest + + class Plugin < Inspec.plugin(2) + plugin_name :'inspec-dsl-test' + + outer_profile_dsl :favorite_grain do + require_relative 'outer_profile_dsl' + InspecPlugins::DslTest::OuterProfileDslFavoriteGrain + end + + control_dsl :favorite_fruit do + require_relative 'control_dsl' + InspecPlugins::DslTest::ControlDslFavoriteFruit + end + + describe_dsl :favorite_vegetable do + require_relative 'describe_dsl' + InspecPlugins::DslTest::DescribeDslFavoriteVegetable + end + + test_dsl :favorite_legume do + require_relative 'test_dsl' + InspecPlugins::DslTest::TestDslFavoriteLegume + end + + resource_dsl :food_type do + require_relative 'resource_dsl' + InspecPlugins::DslTest::ResourceDslFoodType + end + end + end +end \ No newline at end of file diff --git a/test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test/resource_dsl.rb b/test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test/resource_dsl.rb new file mode 100644 index 000000000..9de574870 --- /dev/null +++ b/test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test/resource_dsl.rb @@ -0,0 +1,14 @@ +module InspecPlugins + module DslTest + module ResourceDslFoodType + def food_type(food) + if food == :berries + # OK, add an instance method to any berry resource + define_method :'has_drupals?' do + true + end + end + end + end + end +end diff --git a/test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test/test_dsl.rb b/test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test/test_dsl.rb new file mode 100644 index 000000000..7981003f5 --- /dev/null +++ b/test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test/test_dsl.rb @@ -0,0 +1,12 @@ +module InspecPlugins + module DslTest + module TestDslFavoriteLegume + def favorite_legume(legume) + # This is an absurd thing to do, but we're just seeking a way to show that the + # plugin ran, in a way we can detect from the JSON reporter + # I just couldn't find a way to get to the metadata in a way that stuck; so this will do for testing. + raise "My favorite legume is #{legume}" + end + end + end +end diff --git a/test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test/version.rb b/test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test/version.rb new file mode 100644 index 000000000..b202066d6 --- /dev/null +++ b/test/unit/mock/plugins/inspec-dsl-test/lib/inspec-dsl-test/version.rb @@ -0,0 +1,5 @@ +module InspecPlugins + module DslTest + VERSION = '0.1.0'.freeze + end +end \ No newline at end of file diff --git a/test/unit/mock/profiles/dsl_plugins/controls/control_dsl.rb b/test/unit/mock/profiles/dsl_plugins/controls/control_dsl.rb new file mode 100644 index 000000000..6ff5f5133 --- /dev/null +++ b/test/unit/mock/profiles/dsl_plugins/controls/control_dsl.rb @@ -0,0 +1,15 @@ +control 'control-01' do + + # A normal, if dull, example group + describe 'describe-01' do + it { should include '01' } + end + + # Try to use a control_dsl extension + favorite_fruit 'Banana' + + # A normal, if dull, example group + describe 'describe-02' do + it { should include '02' } + end +end \ No newline at end of file diff --git a/test/unit/mock/profiles/dsl_plugins/controls/describe_dsl.rb b/test/unit/mock/profiles/dsl_plugins/controls/describe_dsl.rb new file mode 100644 index 000000000..11701126b --- /dev/null +++ b/test/unit/mock/profiles/dsl_plugins/controls/describe_dsl.rb @@ -0,0 +1,18 @@ +control 'control-02' do + + # A normal, if dull, example group + describe 'describe-01' do + it { should include '01' } + end + + # A normal, if dull, example group + describe 'describe-02' do + it { should include '02' } + # Try to use a describe_dsl extension + favorite_vegetable 'aubergine' + end + + describe 'describe-03' do + it { should include '03' } + end +end \ No newline at end of file diff --git a/test/unit/mock/profiles/dsl_plugins/controls/outer_profile_dsl.rb b/test/unit/mock/profiles/dsl_plugins/controls/outer_profile_dsl.rb new file mode 100644 index 000000000..bb544ed10 --- /dev/null +++ b/test/unit/mock/profiles/dsl_plugins/controls/outer_profile_dsl.rb @@ -0,0 +1,19 @@ +title 'spelt' + +control 'control-01' do + # A normal, if dull, example group + describe 'describe-01' do + it { should include '01' } + end +end + +# Try to use a outer_profile_dsl extension +# This will generate a new control +favorite_grain 'sorghum' + +control 'control-03' do + # A normal, if dull, example group + describe 'describe-03' do + it { should include '03' } + end +end diff --git a/test/unit/mock/profiles/dsl_plugins/controls/resource_dsl.rb b/test/unit/mock/profiles/dsl_plugins/controls/resource_dsl.rb new file mode 100644 index 000000000..903b62eb1 --- /dev/null +++ b/test/unit/mock/profiles/dsl_plugins/controls/resource_dsl.rb @@ -0,0 +1,25 @@ +control 'rdsl-control-01' do + # A normal, if dull, example group + describe 'describe-01' do + it { should include '01' } + end +end + +control 'rdsl-control-02' do + # Try to use a resource that uses a Resource DSL extension + describe favorite_berry('gooseberry') do + it { should be_blendable } + end + + # This directly relies on the effects of the plugin + describe favorite_berry('hackberry') do + it { should have_drupals } + end +end + +control 'rdsl-control-03' do + # A normal, if dull, example group + describe 'describe-03' do + it { should include '03' } + end +end diff --git a/test/unit/mock/profiles/dsl_plugins/controls/test_dsl.rb b/test/unit/mock/profiles/dsl_plugins/controls/test_dsl.rb new file mode 100644 index 000000000..d48939e9c --- /dev/null +++ b/test/unit/mock/profiles/dsl_plugins/controls/test_dsl.rb @@ -0,0 +1,20 @@ +control 'control-01' do + + # A normal, if dull, example group + describe 'describe-01' do + it { should include '01' } + end + + # A normal, if dull, example group + describe 'describe-02' do + it do + # Try to use a test_dsl extension + favorite_legume 'edemame' + should include '02' + end + end + + describe 'describe-03' do + it { should include '03' } + end +end \ No newline at end of file diff --git a/test/unit/mock/profiles/dsl_plugins/inspec.yml b/test/unit/mock/profiles/dsl_plugins/inspec.yml new file mode 100644 index 000000000..ec3e21dde --- /dev/null +++ b/test/unit/mock/profiles/dsl_plugins/inspec.yml @@ -0,0 +1,8 @@ +name: dsl_plugins +title: A profile to exercise the DSL plugin types +maintainer: The Authors +copyright: The Authors +copyright_email: you@example.com +license: Apache-2.0 +summary: An InSpec Compliance Profile +version: 0.1.0 diff --git a/test/unit/mock/profiles/dsl_plugins/libraries/favorite_berry_resource.rb b/test/unit/mock/profiles/dsl_plugins/libraries/favorite_berry_resource.rb new file mode 100644 index 000000000..d76cb575c --- /dev/null +++ b/test/unit/mock/profiles/dsl_plugins/libraries/favorite_berry_resource.rb @@ -0,0 +1,30 @@ +# encoding: utf-8 + +class FavoriteBerry < Inspec.resource(1) + name 'favorite_berry' + desc 'Will it blend?' + example <<~EOE + describe favorite_berry('mulberry') do + it { should blend } + it { should have_drupes } + end + + describe favorite_berry('raspberry pi 3') do + # Oh it will, regardless. + it { should_not blend } + end + EOE + + # This will install the instance method have_drupes? + food_type :berries + + attr_reader :berry_name + + def initialize(berry_name) + @berry_name = berry_name + end + + def blendable? + true + end +end \ No newline at end of file diff --git a/test/unit/plugin/v2/api_dsl_test.rb b/test/unit/plugin/v2/api_dsl_test.rb new file mode 100644 index 000000000..b9ccd16ff --- /dev/null +++ b/test/unit/plugin/v2/api_dsl_test.rb @@ -0,0 +1,45 @@ +# Tests for the *DSL plugin types + +require 'minitest/autorun' +require 'minitest/test' +require 'byebug' + +require_relative '../../../../lib/inspec/plugin/v2' + +module DslUnitTests + + [ + :outer_profile_dsl, + :control_dsl, + :describe_dsl, + :test_dsl, + :resource_dsl, + ].each do |plugin_type_under_test| + + Class.new(MiniTest::Test) do + # Assign name to anonymous class, so test output is meaningful + Object.const_set(plugin_type_under_test.to_s.upcase + '_UnitTests', self) + + # One day I will understand Ruby scoping and closures. + # Until then, re-expose this as class variable. + @@plugin_type = plugin_type_under_test + + def test_calling_Inspec_dot_plugin_with_plugin_type_returns_the_base_class + klass = Inspec.plugin(2, @@plugin_type) + assert_kind_of Class, klass + assert_equal 'Inspec::Plugin::V2::PluginType::Dsl', klass.name + end + + def test_plugin_type_base_classes_can_be_accessed_by_name + klass = Inspec::Plugin::V2::PluginBase.base_class_for_type(@@plugin_type) + assert_kind_of Class, klass + assert_equal 'Inspec::Plugin::V2::PluginType::Dsl', klass.name + end + + def test_plugin_type_registers_an_activation_dsl_method + klass = Inspec::Plugin::V2::PluginBase + assert_respond_to klass, @@plugin_type, "Activation method for #{@@plugin_type}" + end + end + end +end \ No newline at end of file diff --git a/test/unit/plugin/v2/loader_test.rb b/test/unit/plugin/v2/loader_test.rb index 421d5f7ac..84a30757d 100644 --- a/test/unit/plugin/v2/loader_test.rb +++ b/test/unit/plugin/v2/loader_test.rb @@ -187,7 +187,7 @@ class PluginLoaderTests < MiniTest::Test # 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`' + assert_respond_to registry, :activate, 'Registry should respond to `activate`' # Finding an Activator assert_kind_of Array, status.activators, 'status should have an array for activators' @@ -205,7 +205,7 @@ class PluginLoaderTests < MiniTest::Test 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') + registry.activate(:mock_plugin_type, :'meaning-of-life-the-universe-and-everything') # Activation postconditions assert activator.activated?, 'Test activator should be activated after activate'