Merge pull request #5007 from inspec/cw/reporters-as-plugins

Reporters as Plugins
This commit is contained in:
James Stocks 2020-05-22 16:10:29 +01:00 committed by GitHub
commit 23ed9bcf06
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 785 additions and 25 deletions

View file

@ -42,14 +42,14 @@ You can tell Chef InSpec to use a different config directory using the INSPEC_CO
Top-level entries in the JSON file:
* `plugins_config_version` - must have the value "1.0.0". Reserved for future format changes.
* `plugins` - an Array of Hashes, each containing information about plugins that are expected to be installed
* `plugins_config_version`| must have the value "1.0.0". Reserved for future format changes.
* `plugins`| an Array of Hashes, each containing information about plugins that are expected to be installed
Each plugin entry may have the following keys:
* `name` - Required. String name of the plugin. Internal machine name of the plugin. Must match `plugin_name` DSL call (see Plugin class below).
* `installation_type` - Optional, default "gem". Selects a loading mechanism, may be either "path" or "gem"
* `installation_path` - Required if installation_type is "path". A `require` will be attempted against this path. It may be absolute or relative; Chef InSpec adds both the process current working directory as well as the Chef InSpec installation root to the load path.
* `name`| Required. String name of the plugin. Internal machine name of the plugin. Must match `plugin_name` DSL call (see Plugin class below).
* `installation_type`| Optional, default "gem". Selects a loading mechanism, may be either "path" or "gem"
* `installation_path`| Required if installation_type is "path". A `require` will be attempted against this path. It may be absolute or relative; Chef InSpec adds both the process current working directory as well as the Chef InSpec installation root to the load path.
TODO: keys for gem installations
@ -301,7 +301,7 @@ The minimum needed for a command is a call to `desc` to set the help message, an
```ruby
desc 'Reports on teaspoons in your beverage, always bad news'
def count
# Someone has executed `inspec sweeten count` - do whatever that entails
# Someone has executed `inspec sweeten count`| do whatever that entails
case beverage_type
when :soda
puts 12
@ -328,6 +328,159 @@ no_command do
end
```
## Implementing Reporter Plugins
Reporter plugins offer the opportunity to customize or create entirely new output formats for Chef InSpec. Reporter plugins operate at the very end of the Chef InSpec run when all test data has finalized.
### Declare your plugin activators
In your `plugin.rb`, include one or more `reporter` activation blocks. The activation block name will be matched against the value passed into the `--reporter` option. If a match occurs, your activator will fire, which loads any needed libraries, and return your implementation class.
#### Reporter Activator Example
```ruby
# In plugin.rb
module InspecPlugins::Sweeten
class Plugin < Inspec.plugin(2)
# ... other plugin stuff
reporter :sweet do
require_relative 'reporter.rb'
InspecPlugins::Sweeten::Reporter
end
end
end
```
Like any activator, the block above will only be called if needed. For Reporter plugins, the plugin system examines the `--reporter` argument, or the `reporter:` JSON config option, and looks for the activation name as a prefix. Multiple Reporter activations may occur if several different names match, though each activation will only occur once.
```bash
you@machine $ inspec exec --reporter sweet # Your Reporter implementation is activated and executed
you@machine $ inspec exec --reporter json # Your Reporter implementation is not activated
```
### Implementation class for Reporters
In your `reporter.rb`, you should begin by requesting the superclass from `Inspec.plugin`:
```ruby
module InspecPlugins::Sweeten
class Reporter < Inspec.plugin(2, :reporter)
def render
# examine run_data and call output()
end
def self.run_data_schema_constraints
"~> 0.0" # Accept any non-breaking change
end
end
end
```
### Implementing your Reporter
#### Implement render()
The primary responsibility you must fulfill is to implement render. Typically, you will examine the `run_data` structure (documented below), which is provided as an accessor. Call `output(String, newline_wanted = true)` to send output.
#### Implement self.run_data_schema_constraints
The run_data API is versioned. Your plugin should declare which version(s) it is compatible with by implementing a simple class method, which should return a GemSpec-like constraint. Currently, a simple warning is issued if a version mismatch occurs.
The run_data version scheme follows a data-oriented SemVer approach:
* bump patch: fixing a bug in the provenance or description of a data element, no key changes
* bump minor: adding new data elements
* bump major: deleting or renaming data elements
Prior to version 1.0.0, the API is considered unstable, as per SemVer. The current plan is to bump the major version to 1.0.0 when all of the existing core reporters have been migrated to plugins. It is probable that new data elements and new Hash compatibility behavior will be added during the core reporter plugin conversion process.
#### The run_data structure
The `run_data` object contains all data from the Chef InSpec run. Here is an overview of this object:
| Field | Description |
|-------|-------------|
|`run_data.controls`| Array of Control records. Flattened copy of those found in |`run_data.profiles[*].controls[*]`. See there for details.|
|`run_data.platform.name`| String, name of the OS/API that the run targeted.|
|`run_data.platform.release`| String, version number of the OS/API that the run targeted.|
|`run_data.platform.target`| String, target URI|
|`run_data.profiles`| Array of Profile records|
|`run_data.profiles[0].controls`| Array of Control records|
|`run_data.profiles[0].controls[0].code`| String, source code of the control|
|`run_data.profiles[0].controls[0].desc`| String, concatenated copy of all desc tags|
|`run_data.profiles[0].controls[0].descriptions`| Hash, Symbol => String, collection of all the `desc` tags.|
|`run_data.profiles[0].controls[0].id`| String, ID (name) of the control|
|`run_data.profiles[0].controls[0].impact`| Float, severity of the control range 0.0 .. 1.0|
|`run_data.profiles[0].controls[0].refs`| Array of Ref records for `ref` tags|
|`run_data.profiles[0].controls[0].refs[0].ref`| String, human meaningful reference|
|`run_data.profiles[0].controls[0].refs[0].url`| String, URL for reference|
|`run_data.profiles[0].controls[0].results`| Array of Result records, which represent Describe blocks|
|`run_data.profiles[0].controls[0].results[0].backtrace`| Array of Strings, stacktrace if an error occurred.|
|`run_data.profiles[0].controls[0].results[0].code_desc`| String, generated complete description of the test|
|`run_data.profiles[0].controls[0].results[0].exception`| String, name of an exception thrown (if any)|
|`run_data.profiles[0].controls[0].results[0].expectation_message`| String, generated phrase like "is expected to equal 2"|
|`run_data.profiles[0].controls[0].results[0].message`| String, human-friendly text present when test has failed"|
|`run_data.profiles[0].controls[0].results[0].resource`| Undocumented and usually unpopulated; try exploring resource_title |
|`run_data.profiles[0].controls[0].results[0].resource_name`| String, name of the resource used in the test|
|`run_data.profiles[0].controls[0].results[0].resource_title`| Anonymous Class, the actual instance of the Resource. Responds to to_s with the name of the resource.|
|`run_data.profiles[0].controls[0].results[0].run_time`| Float, execution time in seconds for the test|
|`run_data.profiles[0].controls[0].results[0].skip_message`| String, if the test was skipped, explains why (user provided)|
|`run_data.profiles[0].controls[0].results[0].start_time`| DateTime, time the test started executing|
|`run_data.profiles[0].controls[0].results[0].status`| String, one of "passed", "failed", or "skipped".|
|`run_data.profiles[0].controls[0].source_location.line`| Integer, line number of source code where control begins|
|`run_data.profiles[0].controls[0].source_location.ref`| String, relative path to file in which source code resides|
|`run_data.profiles[0].controls[0].tags`| Hash, String => String, collection of `tag`s.|
|`run_data.profiles[0].controls[0].title`| String, optional title of control.|
|`run_data.profiles[0].controls[0].waiver_data.expiration_date`| DateTime, time at which the waiver expires if any|
|`run_data.profiles[0].controls[0].waiver_data.justification`| String, user-provided reason for applying the waiver|
|`run_data.profiles[0].controls[0].waiver_data.run`| Boolean, whether the control should run even if waivered|
|`run_data.profiles[0].controls[0].waiver_data.skipped_due_to_waiver`| Boolean, whether the control was marked `skipped` due to the |waiver system
|`run_data.profiles[0].controls[0].waiver_data.message`| Reserved|
|`run_data.profiles[0].copyright`| String, Copyright text from inspec.yml|
|`run_data.profiles[0].copyright_email`| String, Copyright email from inspec.yml|
|`run_data.profiles[0].depends`| Array of Dependency records|
|`run_data.profiles[0].depends[0].branch`| branch name if it was a git reference|
|`run_data.profiles[0].depends[0].commit`| commit ref if it was a git reference|
|`run_data.profiles[0].depends[0].compliance`| String, "user/profilename" on Automate server if it was an Automate reference|
|`run_data.profiles[0].depends[0].git`| location of dependent profile if it was a git reference|
|`run_data.profiles[0].depends[0].name`| name (assigned alias) of dependent profile|
|`run_data.profiles[0].depends[0].path`| location of dependent profile if it was a local reference|
|`run_data.profiles[0].depends[0].relative_path`| relative path within clone if it was a git reference|
|`run_data.profiles[0].depends[0].skip_message`| Reason if status is "skipped"|
|`run_data.profiles[0].depends[0].supermarket`| String, "user/profilename" on Supermarket server if it was a Supermarket reference|
|`run_data.profiles[0].depends[0].status`| String, one of "loaded" or "skipped"|
|`run_data.profiles[0].depends[0].tag`| tag ref if it was a git reference|
|`run_data.profiles[0].depends[0].version`| semver tag if it was a git reference|
|`run_data.profiles[0].depends[0].url`| location of dependent profile if it was a URL reference|
|`run_data.profiles[0].groups`| Array, of Group records, describing the files that contained controls|
|`run_data.profiles[0].groups[0].controls`| Array of Strings, the IDs of the controls in the file|
|`run_data.profiles[0].groups[0].id`| String, typically a relative path like `controls/myfile.rb`|
|`run_data.profiles[0].groups[0].title`| String, value of a `title` DSL command in the file|
|`run_data.profiles[0].inputs`| Array of Input records describing inputs present in profile|
|`run_data.profiles[0].inputs[0].name`| String name of Input|
|`run_data.profiles[0].inputs[0].options.required`| Boolean, whether input was required.|
|`run_data.profiles[0].inputs[0].options.type`| String, type constraint on input, if any|
|`run_data.profiles[0].inputs[0].options.value`| Value of Input|
|`run_data.profiles[0].license`| String, name of license for the profile|
|`run_data.profiles[0].maintainer`| String, name of the maintainer|
|`run_data.profiles[0].name`| String, machine name of the profile|
|`run_data.profiles[0].parent_profile`| String, name of the parent profile if this is a dependency|
|`run_data.profiles[0].sha256`| String, checksum of the profile|
|`run_data.profiles[0].skip_message`| String, message indicating why the profile was not loaded if status is "skipped"|
|`run_data.profiles[0].summary`| String, A one-line summary from the inspec.yml|
|`run_data.profiles[0].supports`| Array of Support records indicating platform support|
|`run_data.profiles[0].supports[0].platform_family`| Platform restriction by family|
|`run_data.profiles[0].supports[0].platform_name`| Platform restriction by name|
|`run_data.profiles[0].supports[0].platform`| Platform restriction by name|
|`run_data.profiles[0].supports[0].release`| Platform restriction by release|
|`run_data.profiles[0].status`| String, one of "loaded" or "skipped"|
|`run_data.statistics.controls.failed.total`| Integer, total count of failing controls|
|`run_data.statistics.controls.passed.total`| Integer, total count of passing controls|
|`run_data.statistics.controls.skipped.total`| Integer, total count of passing controls|
|`run_data.statistics.controls.total`| Integer, total count of controls|
|`run_data.statistics.duration`| Float, time in seconds for the execution of Resources.|
|`run_data.version`| A String, such as "4.18.108" representing the Chef InSpec version.|
## Implementing Input Plugins
Input plugins provide values for Chef InSpec Inputs - the parameters you can place within profile control code.

View file

@ -16,16 +16,13 @@ Train Plugins allow Chef InSpec to speak to new kinds of targets (typically new
Currently, each plugin can offer one or more of these capabilities:
* define new output formats ("reporters")
* input sources
* 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)
* attribute fetchers to allow reading Chef InSpec attributes from new sources (for example, a remote encrypted key-value store)
## How do I find out which plugins are available?
The Chef InSpec CLI can tell you which plugins are available:

View file

@ -328,21 +328,36 @@ module Inspec
def validate_reporters!(reporters)
return if reporters.nil?
# TODO: move this into a reporter plugin type system
valid_types = %w{
automate
cli
# These "reporters" are actually RSpec Formatters.
# json-rspec is our alias for RSpec's json formatter.
rspec_built_in_formatters = %w{
documentation
html
json-rspec
progress
}
# These are true reporters, but have not been migrated to be plugins yet.
# Tracked on https://github.com/inspec/inspec/issues/3667
inspec_reporters_that_are_not_yet_plugins = %w{
automate
cli
json
json-automate
json-min
json-rspec
junit
progress
yaml
}
# Additional reporters may be loaded via plugins. They will have already been detected at
# this point (see v2_loader.load_all in cli.rb) but they may not (and need not) be
# activated at this point. We only care about their existance and their name, for validation's sake.
plugin_reporters = Inspec::Plugin::V2::Registry.instance\
.find_activators(plugin_type: :reporter)\
.map(&:activator_name).map(&:to_s)
valid_types = rspec_built_in_formatters + inspec_reporters_that_are_not_yet_plugins + plugin_reporters
reporters.each do |reporter_name, reporter_config|
raise NotImplementedError, "'#{reporter_name}' is not a valid reporter type." unless valid_types.include?(reporter_name)

View file

@ -0,0 +1,68 @@
require_relative "../../../run_data"
module Inspec::Plugin::V2::PluginType
class Reporter < Inspec::Plugin::V2::PluginBase
register_plugin_type(:reporter)
attr_reader :run_data
def initialize(config)
@config = config
# Trim the run_data while still a Hash; if it is huge, this
# saves on conversion time
@run_data = config[:run_data] || {}
apply_report_resize_options
unless Inspec::RunData.compatible_schema?(self.class.run_data_schema_constraints)
# Best we can do is warn here, the InSpec run has finished
# TODO: one day, perhaps switch RunData implementations to try to satisfy constraints?
Inspec::Log.warn "Reporter does not support RunData API (#{Inspec::RunData::SCHEMA_VERSION}), Reporter constraints: '#{self.class.run_data_schema_constraints}'"
end
# Convert to RunData object for consumption by Reporter
@run_data = Inspec::RunData.new(@run_data)
@output = ""
end
# This is a temporary duplication of code from lib/inspec/reporters/base.rb
# To be DRY'd up once the core reporters become plugins...
# Apply options such as message truncation and removal of backtraces
def apply_report_resize_options
runtime_config = Inspec::Config.cached.respond_to?(:final_options) ? Inspec::Config.cached.final_options : {}
message_truncation = runtime_config[:reporter_message_truncation] || "ALL"
trunc = message_truncation == "ALL" ? -1 : message_truncation.to_i
include_backtrace = runtime_config[:reporter_backtrace_inclusion].nil? ? true : runtime_config[:reporter_backtrace_inclusion]
@run_data[:profiles]&.each do |p|
p[:controls].each do |c|
c[:results]&.map! do |r|
r.delete(:backtrace) unless include_backtrace
if r.key?(:message) && r[:message] != "" && trunc > -1
r[:message] = r[:message][0...trunc] + "[Truncated to #{trunc} characters]"
end
r
end
end
end
end
def output(str, newline = true)
@output << str
@output << "\n" if newline
end
def rendered_output
@output
end
# each reporter must implement #render
def render
raise NotImplementedError, "#{self.class} must implement a `#render` method to format its output."
end
def self.run_data_schema_constraints
raise NotImplementedError, "#{self.class} must implement a `run_data_schema_constraints` class method to declare its compatibiltity with the RunData API."
end
end
end

View file

@ -30,7 +30,10 @@ module Inspec::Reporters
when "yaml"
reporter = Inspec::Reporters::Yaml.new(config)
else
raise NotImplementedError, "'#{name}' is not a valid reporter type."
# If we made it here, it must be a plugin, and we know it exists (because we validated it in config.rb)
activator = Inspec::Plugin::V2::Registry.instance.find_activator(plugin_type: :reporter, activator_name: name.to_sym)
activator.activate!
reporter = activator.implementation_class.new(config)
end
# optional send_report method on reporter

64
lib/inspec/run_data.rb Normal file
View file

@ -0,0 +1,64 @@
module Inspec
module HashLikeStruct
def keys
members
end
def key?(item)
members.include?(item)
end
end
RunData = Struct.new(
:controls, # Array of Inspec::RunData::Control (flattened)
:other_checks,
:profiles, # Array of Inspec::RunData::Profile
:platform, # Inspec::RunData::Platform
:statistics, # Inspec::RunData::Statistics
:version # String
) do
include HashLikeStruct
def initialize(raw_run_data)
self.controls = raw_run_data[:controls].map { |c| Inspec::RunData::Control.new(c) }
self.profiles = raw_run_data[:profiles].map { |p| Inspec::RunData::Profile.new(p) }
self.statistics = Inspec::RunData::Statistics.new(raw_run_data[:statistics])
self.platform = Inspec::RunData::Platform.new(raw_run_data[:platform])
self.version = raw_run_data[:version]
end
end
class RunData
# This is the data layout version of RunData.
# We plan to follow a data-oriented version of semver:
# patch: fixing a bug in the provenance or description of a data element, no key changes
# minor: adding new data elements
# major: deleting or renaming data elements
# Less than major version 1.0.0, the API is considered unstable.
# The current plan is to bump the major version to 1.0.0 when all of the existing
# core reporters have been migrated to plugins. It is probable that new data elements
# and new Hash compatibility behavior will be added during the core reporter plugin
# conversion process.
SCHEMA_VERSION = "0.1.0".freeze
def self.compatible_schema?(constraints)
reqs = Gem::Requirement.create(constraints)
reqs.satisfied_by?(Gem::Version.new(SCHEMA_VERSION))
end
Platform = Struct.new(
:name, :release, :target
) do
include HashLikeStruct
def initialize(raw_plat_data)
%i{name release target}.each { |f| self[f] = raw_plat_data[f] || "" }
end
end
end
end
require_relative "run_data/result"
require_relative "run_data/control"
require_relative "run_data/profile"
require_relative "run_data/statistics"

View file

@ -0,0 +1,83 @@
module Inspec
class RunData
Control = Struct.new(
:code, # String
:desc, # String
:descriptions, # Hash with custom keys
:id, # String
:impact, # Float
:refs, # Complex local
:results, # complex standalone
:source_location, # Complex local
:tags, # Hash with custom keys
:title, # String
:waiver_data # Complex local
) do
include HashLikeStruct
def initialize(raw_ctl_data)
self.refs = (raw_ctl_data[:refs] || []).map { |r| Inspec::RunData::Control::Ref.new(r) }
self.results = (raw_ctl_data[:results] || []).map { |r| Inspec::RunData::Result.new(r) }
self.source_location = Inspec::RunData::Control::SourceLocation.new(raw_ctl_data[:source_location] || {})
self.waiver_data = Inspec::RunData::Control::WaiverData.new(raw_ctl_data[:waiver_data] || {})
[
:code, # String
:desc, # String
:descriptions, # Hash with custom keys
:id, # String
:impact, # Float
:tags, # Hash with custom keys
:title, # String
].each do |field|
self[field] = raw_ctl_data[field]
end
end
end
class Control
Ref = Struct.new(
:url, :ref
) do
include HashLikeStruct
def initialize(raw_ref_data)
%i{url ref}.each { |f| self[f] = raw_ref_data[f] }
end
end
SourceLocation = Struct.new(
:line, :ref
) do
include HashLikeStruct
def initialize(raw_sl_data)
%i{line ref}.each { |f| self[f] = raw_sl_data[f] }
end
end
# {
# "expiration_date"=>#<Date: 2077-06-01 ((2479821j,0s,0n),+0s,2299161j)>,
# "justification"=>"Lack of imagination",
# "run"=>false,
# "skipped_due_to_waiver"=>true,
# "message"=>""}
WaiverData = Struct.new(
:expiration_date,
:justification,
:run,
:skipped_due_to_waiver,
:message
) do
include HashLikeStruct
def initialize(raw_wv_data)
# These have string keys in the raw data!
%i{
expiration_date
justification
run
skipped_due_to_waiver
message
}.each { |f| self[f] = raw_wv_data[f.to_s] }
end
end
end
end
end

View file

@ -0,0 +1,109 @@
module Inspec
class RunData
Profile = Struct.new(
:controls, # complex standalone
:copyright,
:copyright_email,
:depends, # complex local
:groups, # complex local
:inputs, # complex local
:license,
:maintainer,
:name,
:sha256,
:status,
:summary,
:supports, # complex local
:parent_profile,
:skip_message,
:waiver_data, # Undocumented but used in JSON reporter - should not be?
:title,
:version
) do
include HashLikeStruct
def initialize(raw_prof_data)
self.controls = (raw_prof_data[:controls] || []).map { |c| Inspec::RunData::Control.new(c) }
self.depends = (raw_prof_data[:depends] || []).map { |d| Inspec::RunData::Profile::Dependency.new(d) }
self.groups = (raw_prof_data[:groups] || []).map { |g| Inspec::RunData::Profile::Group.new(g) }
self.inputs = (raw_prof_data[:inputs] || []).map { |i| Inspec::RunData::Profile::Input.new(i) }
self.supports = (raw_prof_data[:supports] || []).map { |s| Inspec::RunData::Profile::Support.new(s) }
%i{
copyright
copyright_email
license
maintainer
name
sha256
status
summary
title
version
parent_profile
skip_message
waiver_data
}.each do |field|
self[field] = raw_prof_data[field]
end
end
end
class Profile
# Good candidate for keyword_init, but that is not in 2.4
Dependency = Struct.new(
:name, :path, :status, :skip_message, :git, :url, :compliance, :supermarket, :branch, :tag, :commit, :version, :relative_path
) do
include HashLikeStruct
def initialize(raw_dep_data)
%i{name path status skip_message git url supermarket compliance branch tag commit version relative_path}.each { |f| self[f] = raw_dep_data[f] }
end
end
Support = Struct.new(
# snake case
:platform_family, :platform_name, :release, :platform
) do
include HashLikeStruct
def initialize(raw_sup_data)
%i{release platform}.each { |f| self[f] = raw_sup_data[f] }
self.platform_family = raw_sup_data[:"platform-family"]
self.platform_name = raw_sup_data[:"platform-name"]
end
end
# Good candidate for keyword_init, but that is not in 2.4
Group = Struct.new(
:title, :controls, :id
) do
include HashLikeStruct
def initialize(raw_grp_data)
%i{title id}.each { |f| self[f] = raw_grp_data[f] }
[:controls].each { |f| self[f] = raw_grp_data[f] || [] }
end
end
Input = Struct.new(
:name, :options
) do
include HashLikeStruct
def initialize(raw_input_data)
self.name = raw_input_data[:name]
self.options = Inspec::RunData::Profile::Input::Options.new(raw_input_data[:options])
end
end
class Input
Options = Struct.new(
# There are probably others
:value,
:type,
:required
) do
include HashLikeStruct
def initialize(raw_opts_data)
%i{value type required}.each { |f| self[f] = raw_opts_data[f] }
end
end
end
end
end
end

View file

@ -0,0 +1,40 @@
module Inspec
class RunData
Result = Struct.new(
:message, # Human-friendly test failure message
:code_desc, # Generated test description
:expectation_message, # a substring of code_desc
:resource_name, # We try to determine this
:run_time, # Float seconds execution time
:skip_message, # String
:start_time, # DateTime
:status, # String
:resource_title, # Ugly internals
# :waiver_data, # Undocumented tramp data / not exposed in this API
:resource, # Undocumented, what is this
:exception,
:backtrace
) do
include HashLikeStruct
def initialize(raw_res_data)
[
:status, # String
:code_desc, # Generated test description
:expectation_message, # a substring of code_desc
:skip_message, # String
:run_time,
:start_time,
:resource_title,
:resource,
:exception,
:backtrace,
:message,
].each do |field|
self[field] = raw_res_data[field]
end
self.resource_name = raw_res_data[:resource_title].instance_variable_get(:@__resource_name__)&.to_s
end
end
end
end

View file

@ -0,0 +1,36 @@
module Inspec
class RunData
# {:duration=>0.018407, :controls=>{:total=>3, :passed=>{:total=>3}, :skipped=>{:total=>0}, :failed=>{:total=>0}}}
Statistics = Struct.new(
:duration,
:controls
) do
include HashLikeStruct
def initialize(raw_stat_data)
self.controls = Inspec::RunData::Statistics::Controls.new(raw_stat_data[:controls])
self.duration = raw_stat_data[:duration]
end
end
class Statistics
Controls = Struct.new(
:total,
:passed,
:skipped,
:failed
) do
include HashLikeStruct
def initialize(raw_stat_ctl_data)
self.total = raw_stat_ctl_data[:total]
self.passed = Inspec::RunData::Statistics::Controls::Total.new(raw_stat_ctl_data[:passed][:total])
self.skipped = Inspec::RunData::Statistics::Controls::Total.new(raw_stat_ctl_data[:skipped][:total])
self.failed = Inspec::RunData::Statistics::Controls::Total.new(raw_stat_ctl_data[:failed][:total])
end
end
class Controls
Total = Struct.new(:total) do
include HashLikeStruct
end
end
end
end
end

View file

@ -41,8 +41,9 @@ module InspecPlugins
templates_path: TEMPLATES_PATH,
overwrite: options[:overwrite],
file_rename_map: make_rename_map(plugin_type, plugin_name, snake_case),
skip_files: make_skip_list,
skip_files: make_skip_list(template_vars["hooks"].keys),
}
renderer = InspecPlugins::Init::Renderer.new(ui, render_opts)
renderer.render_with_values(template_path, plugin_type + " plugin", template_vars)
@ -72,6 +73,7 @@ module InspecPlugins
File.join("lib", "inspec-plugin-template") => File.join("lib", plugin_name),
File.join("lib", "inspec-plugin-template.rb") => File.join("lib", plugin_name + ".rb"),
File.join("lib", "inspec-plugin-template", "cli_command.rb") => File.join("lib", plugin_name, "cli_command.rb"),
File.join("lib", "inspec-plugin-template", "reporter.rb") => File.join("lib", plugin_name, "reporter.rb"),
File.join("lib", "inspec-plugin-template", "plugin.rb") => File.join("lib", plugin_name, "plugin.rb"),
File.join("lib", "inspec-plugin-template", "version.rb") => File.join("lib", plugin_name, "version.rb"),
File.join("test", "functional", "inspec_plugin_template_test.rb") => File.join("test", "functional", snake_case + "_test.rb"),
@ -168,6 +170,9 @@ module InspecPlugins
if hooks_by_type.key?(:cli_command)
vars[:command_name_dashes] = hooks_by_type[:cli_command].tr("_", "-")
vars[:command_name_snake] = hooks_by_type[:cli_command].tr("-", "_")
elsif hooks_by_type.key?(:reporter)
vars[:reporter_name_dashes] = hooks_by_type[:reporter].tr("_", "-")
vars[:reporter_name_snake] = hooks_by_type[:reporter].tr("-", "_")
end
vars
end
@ -205,19 +210,20 @@ module InspecPlugins
end
end
def make_skip_list
def make_skip_list(requested_hooks)
skips = []
case options[:detail]
when "full"
[]
when "full" # rubocop: disable Lint/EmptyWhen
# Do nothing but allow this case for validation
when "core"
[
skips += [
"Gemfile",
"inspec-plugin-template.gemspec",
"LICENSE",
"Rakefile",
]
when "test-fixture"
[
skips += [
"Gemfile",
"inspec-plugin-template.gemspec",
"LICENSE",
@ -237,6 +243,22 @@ module InspecPlugins
ui.error "Unrecognized value for 'detail': #{options[:detail]} - expected one of full, core, test-fixture"
ui.exit(:usage_error)
end
# Remove hook-specific files
unless requested_hooks.include?(:cli_command)
skips += [
File.join("lib", "inspec-plugin-template", "cli_command.rb"),
File.join("test", "unit", "cli_args_test.rb"),
File.join("test", "functional", "inspec_plugin_template_test.rb"),
]
end
unless requested_hooks.include?(:reporter)
skips += [
File.join("lib", "inspec-plugin-template", "reporter.rb"),
]
end
skips.uniq
end
end
end

View file

@ -28,6 +28,7 @@ module InspecPlugins
# Internal machine name of the plugin. InSpec will use this in errors, etc.
plugin_name :'<%= plugin_name %>'
<% if hooks[:cli_command] %>
# Define a new CLI subcommand.
# The argument here will be used to match against the command line args,
# and if the user said `inspec list-resources`, this hook will get called.
@ -48,6 +49,23 @@ module InspecPlugins
# CLI engine tap into it.
InspecPlugins::<%= module_name %>::CliCommand
end
<% end %>
<% if hooks[:reporter] %>
# Define a new Reporter.
# The argument here will be used to match against the CLI --reporter option.
# `--reporter <%= reporter_name_snake %>` will load your reporter and call its renderer.
reporter :<%= reporter_name_snake %> do
# Calling this hook doesn't mean the reporter is being executed - just
# that we should be ready to do so. So, load the file that defines the
# functionality.
require '<%= plugin_name %>/reporter'
# Having loaded our functionality, return a class that will let the
# reporting engine tap into it.
InspecPlugins::<%= module_name %>::Reporter
end
<% end %>
end
end
end

View file

@ -0,0 +1,27 @@
module InspecPlugins::<%= module_name %>
# This class will provide the actual Reporter implementation.
# Its superclass is provided by another call to Inspec.plugin,
# this time with two args. The first arg specifies we are requesting
# version 2 of the Plugins API. The second says we are making a
# Reporter plugin component, so please make available any DSL needed
# for that.
class Reporter < Inspec.plugin(2, :reporter)
# All a Reporter *must* do is define a render() method that calls
# output(). You should access the run_data accessor to read off the
# results of the run.
def render
# There is much more to explore in the run_data structure!
run_data[:profiles].each do |profile|
output(profile[:title])
profile[:controls].each do |control|
output(control[:title])
control[:results].each do |test|
output(test[:status])
end
end
end
end
end
end

View file

@ -0,0 +1,10 @@
{
"plugins_config_version" : "1.0.0",
"plugins": [
{
"name": "inspec-reporter-test-fixture",
"installation_type": "path",
"installation_path": "test/fixtures/plugins/inspec-reporter-test-fixture/lib/inspec-reporter-test-fixture.rb"
}
]
}

View file

@ -0,0 +1,3 @@
# inspec-reporter-test-fixture
Reporter plugin used to test reporter plugin type in test/functional/plugins_test.rb

View file

@ -0,0 +1,4 @@
libdir = File.dirname(__FILE__)
$LOAD_PATH.unshift(libdir) unless $LOAD_PATH.include?(libdir)
require "inspec-reporter-test-fixture/plugin"

View file

@ -0,0 +1,13 @@
require "inspec-reporter-test-fixture/version"
module InspecPlugins
module ReporterTestFixture
class Plugin < ::Inspec.plugin(2)
plugin_name :'inspec-reporter-test-fixture'
reporter :"test-fixture" do
require "inspec-reporter-test-fixture/reporter"
InspecPlugins::ReporterTestFixture::ReporterImplementation
end
end
end
end

View file

@ -0,0 +1,29 @@
module InspecPlugins::ReporterTestFixture
class ReporterImplementation < Inspec.plugin(2, :reporter)
# The test reporter plugin returns a single line of output, like this:
# pXX:cYY:tZZ
# where XX is the count of profiles
# YY is the count of controls
# ZZ is the count of tests
def render
profile_count = run_data[:profiles].count
control_count = 0
test_count = 0
run_data[:profiles].each do |p|
controls = p[:controls] || []
control_count += controls.count
controls.each do |c|
tests = c[:results] || []
test_count += tests.count
end
end
output("p#{profile_count}c#{control_count}t#{test_count}", true)
end
def self.run_data_schema_constraints
">= 0.0"
end
end
end

View file

@ -0,0 +1,5 @@
module InspecPlugins
module ReporterTestFixture
VERSION = "0.1.0".freeze
end
end

View file

@ -146,6 +146,28 @@ describe "input plugins" do
end
end
#=========================================================================================#
# Reporter plugin type
#=========================================================================================#
describe "reporter plugins" do
# The test reporter plugin returns a single line of output, like this:
# pXX:cYY:tZZ
# where XX is the count of profiles
# YY is the count of controls
# ZZ is the count of tests
let(:env) { { INSPEC_CONFIG_DIR: "#{config_dir_path}/reporter_plugin" } }
# Test a flat profile - dependencies/profile_c is a simple one
describe "when using a custom reporter on a profile with one control" do
it "finds the single control" do
cmd = "exec #{profile_path}/dependencies/profile_c --reporter test-fixture"
run_result = run_inspec_process(cmd, env: env)
_(run_result.stderr).must_be_empty
_(run_result.stdout).must_include "p1c1t1"
end
end
end
#=========================================================================================#
# inspec plugin command
#=========================================================================================#

View file

@ -0,0 +1,39 @@
require "helper"
require "inspec/plugin/v2"
describe "Reporter plugin type" do
describe "when registering the plugin type superclass" do
it "returns the superclass when calling the global definition method" do
klass = Inspec.plugin(2, :reporter)
_(klass).must_be_kind_of Class
_(klass).must_equal Inspec::Plugin::V2::PluginType::Reporter
end
it "returns the superclass when referenced by alias" do
klass = Inspec::Plugin::V2::PluginBase.base_class_for_type(:reporter)
_(klass).must_be_kind_of Class
_(klass).must_equal Inspec::Plugin::V2::PluginType::Reporter
end
it "registers an activation dsl method" do
klass = Inspec::Plugin::V2::PluginBase
_(klass).must_respond_to :reporter
end
end
describe "when examining the specific plugin type API" do
[
# API instance methods
:render, # pure virtual
:output, # helper
:rendered_output, # accessor
:run_data, # accessor
].each do |api_method|
it "should define a '#{api_method}' method in the superclass" do
klass = Inspec::Plugin::V2::PluginType::Reporter
_(klass.method_defined?(api_method)).must_equal true
end
end
end
end