Appendix A - Writing Plural Resources
10. Plural Resources
You might have noticed that many InSpec resources have a "plural" version. For example, user
has a users
counterpart, and package
has packages
.
Plural resources examine platform objects in bulk.
For example,
- sorting through which packages are installed on a system, or
- which virtual machines are on a cloud provider.
- all processes running more than an hour, or all VMs on a particular subnet.
Plural resources usually include functions to query the set of objects it represents by an attribute, like so:
describe users.where(uid: 0).entries do
it { should eq ['root'] }
its('uids') { should eq [1234] }
its('gids') { should eq [1234] }
end
This test queries all users to confirm that the only one with a uid of zero is the root user.
Plural InSpec resources are created by leveraging Ruby's FilterTable module to capture system data. Let's dig into how FilterTable works so that you can write your own plural resources.
10.1. Using FilterTable to write a Plural Resource
FilterTable is intended to help you author plural resources with stucture data. You declare a number of columns of data, attach them to a FilterTable object, and then write methods that the FilterTable can call to populate those columns. You can also define custom matchers that make sense for whatever data you are modeling (to go alongside the standard InSpec matchers like be_in
,include
, and cmp
). You wind up with a queryable structure:
inspec> etc_hosts.entries
=>
[#<struct ip_address="127.0.0.1", primary_name="localhost", all_host_names=["localhost", "localhost.localdomain", "localhost4", "localhost4.localdomain4"]>,
#<struct ip_address="::1", primary_name="localhost6", all_host_names=["localhost6", "localhost6.localdomain6"]>,
#<struct ip_address="127.0.0.1", primary_name="test1.org", all_host_names=["test1.org"]>,
#<struct ip_address="127.0.0.1", primary_name="test2.org", all_host_names=["test2.org"]>,
#<struct ip_address="127.0.0.1", primary_name="test3.org", all_host_names=["test3.org"]>,
#<struct ip_address="127.0.0.1", primary_name="test4.org", all_host_names=["test4.org"]>]
10.1.1. May I have multiple FilterTable installations on a class?
In theory, yes - that would be used to implement different data fetching / caching strategies. It is a very advanced usage, and no core resources currently do this, as far as we know.
10.2. FilterTable Hands-On
Let's take a look at the structure of a resource that leverages FilterTable. We will write a dummy resource that models a small group of students. Our resource will describe each student's name, grade, and the toys they have. Usually, a resource will include some methods that reach out the system under test to populate the FilterTable with real system data, but for now we're just going to hard-code in some dummy data.
- Create new profile
inspec init profile filtertable-test
- Place following file as custom resource in
libraries
directory asfilter.rb
.
Tips
You can also use inspec init resource <your-resource-name>
to create the template for your resource. When following the prompts, you can choose "plural" to create the template for a plural resource.
require 'inspec/utils/filter'
class Filtertable < Inspec.resource(1)
name "filtertable"
supports platform: "linux"
filter_table = FilterTable.create
filter_table.register_column(:name, field: :name)
filter_table.register_column(:grade, field: :grade)
filter_table.register_column(:toys, field: :toys)
filter_table.register_custom_matcher(:has_bike?) { |filter_table| filter_table.toys.flatten.include?('bike') }
filter_table.register_custom_matcher(:has_middle_schooler?) { |filter_table| filter_table.grade.uniq.any?{ |grade| grade >= 6} }
filter_table.register_custom_property(:bike_count) { |filter_table| filter_table.toys.flatten.include?('bike').count }
filter_table.register_custom_property(:middle_schooler_count) { |filter_table| filter_table.where{ grade >= 6 }.count }
filter_table.install_filter_methods_on_resource(self, :fetch_data)
def fetch_data
# This method should return an array of hashes - the raw data. We'll hardcode it here.
[
{ name: "Sarah", grade: 7, toys: ['car','train','bike']},
{ name: "John", grade: 4, toys: ['top','bike']},
{ name: "Donny", grade: 5, toys: ['train','nintento']},
{ name: "Susan", grade: 7, toys: ['car','gameboy','bike']},
]
end
end
Now we've got a nice blob of code in a resource file. Let's load this resource in the InSpec shell and see what we can do with it.
10.2.1. Run the InSpec shell with a custom resource
Invoking the InSpec shell with inspec shell
will give you access to all the core InSpec resources by default, but InSpec does not automatically know about your locally defined resources unless you point them out. If you're testing a local resource, use the --depends
flag and pass in the profile directory that your resource lives in.
inspec shell --depends /path/to/profile/root/
10.2.2. Fetching Data
FilterTables organize their data into columns. Your resource will declare a number of columns using the register_column
method.
Once you declare the columns that you want in your FilterTable (name
, grade
, and toys
in our example), you need to insert some data into them using the install_filter_methods_on_resource
method. That method takes two args -- self
and a data structure that is an array of hashes. The array of hashes will be matched up to the columns you defined using the hashes' keys. For our example we hard-coded this data structure, which is returned by the fetch_data
method.
As we mentioned earlier, a real InSpec resource will include methods that will populate the resource with real system data. Take a look at the Firewalld resource for an example of a resource that does this -- note the resource is ultimately invoking a shell command (firewall-ctl
) to populate its FilterTable. There are plenty of other InSpec resources using FilterTable that you can find in the source code if you are interested in more examples.
10.2.3. Custom Matcher Examples
After we define our FilterTable's columns, we can also define custom matchers just like we do in singluar resources using register_custom_matcher
. That function takes a block as an argument that defines a boolean expression that tells InSpec when that matcher should return true
. Note that the matcher's logic can get pretty complicated -- that's why we're shoving all of it into a resource so we can avoid having to write complicated tests.
has_bike?
describe filtertable.where( name: "Donny" ) do
it { should have_bike }
end
Profile: inspec-shell
Version: (not specified)
filtertable with name == "Donny"
× should have bike
expected #has_bike? to return true, got false
Test Summary: 0 successful, 1 failure, 0 skipped
describe filtertable.where( name: "Sarah" ) do
it { should have_bike }
end
Profile: inspec-shell
Version: (not specified)
filtertable with name == "Sarah"
✔ should have bike
Test Summary: 1 successful, 0 failures, 0 skipped
In the simplest examples, we filter the table down to a single student using where
(more on where
in a minute) and invoke a matcher that checks if that student has a bike
in their list of toys. We can write matchers to have whatever logic we like. For example, while has_bike
checks if all of the students in the table under test have a bike, while has_middle_schooler
checks if any student in the table under test is in the 7th grade or higher.
has_middle_schooler?
describe filtertable.where { name =~ /Sarah|John/ } do
it { should have_middle_schooler }
end
Profile: inspec-shell
Version: (not specified)
Target ID:
filtertable with name =~ /Sarah|John/
✔ is expected to have middle schooler
Test Summary: 1 successful, 0 failures, 0 skipped
10.2.4. Custom Property
We can also declare custom properties for our resource, using whatever logic we like, just like we did for our custom matchers. Properties can be referred to with its
syntax in an InSpec test.
bike_count
describe filtertable do
its('bike_count') { should eq 3 }
end
Profile: inspec-shell
Version: (not specified)
Target ID:
filtertable
✔ bike_count is expected to eq 3
Test Summary: 1 successful, 0 failures, 0 skipped
middle_schooler_count
describe filtertable do
its('middle_schooler_count') { should eq 4 }
end
Profile: inspec-shell
Version: (not specified)
Target ID:
filtertable
× middle_schooler_count is expected to eq 4
expected: 4
got: 2
(compared using ==)
Test Summary: 0 successful, 1 failure, 0 skipped
10.2.5. Suggested activity
To get a better feel for how FilterTable works, we suggest you add a few extra features to the sample given above.
- Add a field to the data array and reflect the change in filter table
- Add a custom matcher
- Add a custom property
Then write some tests to see how your new matchers and properties work.
10.3. Predefined Methods for FilterTable
When you create a new FilterTable, these methods are installed automatically: where
, entries
, raw_data
, count
, and exist?
. Each is very useful both for writing tests in and of themselves and for creating custom matchers and properties inside the resource code.
where
method
10.3.1 The You may have already noticed that a bunch of our example tests are using the where
method on the FilterTable object. This method returns a new FilterTable object created from the rows of the original table that match the query provided to where
. If you have experience with relational databases, think of it like the WHERE
clause in a SQL query. This method is extremely flexible; we give some examples below.
- If you call
where
as a method with no block and passing hash params, with keys you know are in the raw data, it will fetch the raw data, then filter row-wise and return the resulting Table. - Multiple criteria are joined with a logical AND.
- The filtering is fancy, not just straight equality.
describe things.where(color: 'red') do
its('count') { should cmp 2 }
end
# Regexes
describe things.where(color: /^re/) do
its('count') { should cmp 2 }
end
# It eventually falls out to '===' comparison
# Here, range membership 1..2
describe things.where(thing_id: (1..2)) do
its('count') { should cmp 2 }
end
# irregular rows are supported
# Only one row has the :tackiness key, with value 'very'.
describe things.where(tackiness: 'very') do
its('count') { should cmp 1 }
end
where
method with blocks
10.3.1.1. You can also call the where
method with a block. The block is executed row-wise. If it returns truthy, the row is included in the results. Each field declared with the register_custom_property
configuration method is available as a data accessor.
# You can have any logic you want in the block
describe things.where { true } do
its('count') { should cmp 3 }
end
# You can access any fields you declared using `register_column`
describe things.where { thing_id > 2 } do
its('count') { should cmp 1 }
end
where
calls and Tables without re-fetching raw data
10.3.1.2. Chaining The first time where
is called, the data fetcher method is called. where
performs filtration on the raw data table. It then constructs a new FilterTable::Table
, directly passing in the filtered raw data; this is then the return value from where
.
# This only calls fetch_data once
describe things.where(color: :red).where { thing_id > 2 } do
its('count') { should cmp 1 }
end
Some other methods return a Table object, and they may be chained without a re-fetch as well.
entries
method
10.3.2. The The other register_filter_method
call enables a pre-defined method, entries
. entries
is much simpler than where
- in fact, its behavior is unrelated. It returns an encapsulated version of the raw data - a plain array, containing Structs as row-entries. Each struct has an attribute for each time you called register_column
.
Importantly, note that the return value of entries
is not the resource, nor the Table - in other words, you cannot chain it. However, you can call entries
on any Table.
If you call entries
without chaining it after where
, calling entries will trigger the call to the data fetching method.
# Access the entries array
describe things.entries do
# This is Array#count, not the resource's `count` method
its('count') { should cmp 3}
end
# Access the entries array after chaining off of where
describe things.where(color: :red).entries do
# This is Array#count, not the resource's or table's `count` method
its('count') { should cmp 2}
end
# You can access the struct elements as a method, as a hash keyed on symbol, or as a hash keyed on string
describe things.entries.first.color do
it { should cmp :red }
end
describe things.entries.first[:color] do
it { should cmp :red }
end
describe things.entries.first['color'] do
it { should cmp :red }
end
exist?
matcher
10.3.3. The This register_custom_matcher
call:
filter_table_config.register_custom_matcher(:exist?) { |filter_table| !filter_table.entries.empty? }
causes a new method to be defined on both the resource class and the Table class. The body of the method is taken from the block that is provided. When the method it called, it will receive the FilterTable::Table
instance as its first parameter. (It may also accept a second param, but that doesn't make sense for this method - see thing_ids).
As when you are implementing matchers on a singular resource, the only thing that distinguishes this as a matcher is the fact that it ends in ?
.
# Bare call on the matcher (called as a method on the resource)
describe things do
it { should exist }
end
# Chained on where (called as a method on the Table)
describe things.where(color: :red) do
it { should exist }
end
count
property
10.3.4. The This register_custom_property
call:
filter_table_config.register_custom_property(:count) { |filter_table| filter_table.entries.count }
causes a new method to be defined on both the resource class and the Table class. As with exists?
, the body is taken from the block.
# Bare call on the property (called as a method on the resource)
describe things do
its('count') { should cmp 3 }
end
# Chained on where (called as a method on the Table)
describe things.where(color: :red) do
its('count') { should cmp 2 }
end
raw_data
method
10.3.5. The Unlike entries
, which wraps each row in a Struct and omits undeclared fields, raw_data
simply returns the actual raw data array-of-hashes. It is not dup
'd. People definitely use this out in the wild, even though it returns a rougher data structure.
tacky_things = things.where(color: :blue).raw_data.select { |row| row[:tackiness] }
tacky_things.map { |row| row[:thing_id] }.each do |thing_id|
# Use to audit a singular Thing
describe thing(thing_id) do
it { should_not be_paisley }
end
end
10.4 FilterTable Examples
FilterTable is a very flexible and powerful class that works well when designing plural resources. As always, if you need to write a plural resource, we encourage you to examine existing resources in the InSpec source code to see how other developers have implemented it. Some good examples include: