inspec 2.1.84 → 2.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -8
  3. data/README.md +1 -0
  4. data/docs/dev/filtertable-internals.md +353 -0
  5. data/docs/dev/filtertable-usage.md +533 -0
  6. data/docs/matchers.md +36 -36
  7. data/docs/profiles.md +2 -2
  8. data/docs/resources/apache.md.erb +1 -1
  9. data/docs/resources/aws_elb.md.erb +144 -0
  10. data/docs/resources/aws_elbs.md.erb +242 -0
  11. data/docs/resources/aws_flow_log.md.erb +118 -0
  12. data/docs/resources/aws_iam_groups.md.erb +34 -1
  13. data/docs/resources/crontab.md.erb +10 -6
  14. data/docs/resources/dh_params.md.erb +71 -65
  15. data/docs/resources/docker_service.md.erb +1 -1
  16. data/docs/resources/etc_fstab.md.erb +1 -1
  17. data/docs/resources/firewalld.md.erb +1 -1
  18. data/docs/resources/http.md.erb +1 -1
  19. data/docs/resources/iis_app.md.erb +1 -1
  20. data/docs/resources/inetd_conf.md.erb +1 -1
  21. data/docs/resources/nginx.md.erb +1 -1
  22. data/docs/resources/npm.md.erb +9 -1
  23. data/docs/resources/os.md.erb +21 -19
  24. data/docs/resources/shadow.md.erb +37 -31
  25. data/docs/resources/x509_certificate.md.erb +2 -2
  26. data/examples/custom-resource/README.md +3 -0
  27. data/examples/custom-resource/controls/example.rb +7 -0
  28. data/examples/custom-resource/inspec.yml +8 -0
  29. data/examples/custom-resource/libraries/batsignal.rb +20 -0
  30. data/examples/custom-resource/libraries/gordon.rb +21 -0
  31. data/lib/inspec/reporters/junit.rb +1 -0
  32. data/lib/inspec/resource.rb +8 -0
  33. data/lib/inspec/version.rb +1 -1
  34. data/lib/resource_support/aws.rb +3 -0
  35. data/lib/resources/aws/aws_elb.rb +81 -0
  36. data/lib/resources/aws/aws_elbs.rb +78 -0
  37. data/lib/resources/aws/aws_flow_log.rb +102 -0
  38. data/lib/resources/aws/aws_iam_groups.rb +1 -2
  39. data/lib/resources/aws/aws_iam_users.rb +65 -47
  40. data/lib/resources/npm.rb +15 -2
  41. data/lib/resources/package.rb +1 -1
  42. data/lib/utils/filter.rb +243 -85
  43. metadata +15 -2
@@ -0,0 +1,102 @@
1
+ class AwsFlowLog < Inspec.resource(1)
2
+ name 'aws_flow_log'
3
+ supports platform: 'aws'
4
+ desc 'This resource is used to test the attributes of a Flow Log.'
5
+ example <<~EOT
6
+ describe aws_flow_log('fl-9c718cf5') do
7
+ it { should exist }
8
+ end
9
+ EOT
10
+
11
+ include AwsSingularResourceMixin
12
+
13
+ def to_s
14
+ "AWS Flow Log #{id}"
15
+ end
16
+
17
+ def resource_type
18
+ case @resource_id
19
+ when /^eni/
20
+ @resource_type = 'eni'
21
+ when /^subnet/
22
+ @resource_type = 'subnet'
23
+ when /^vpc/
24
+ @resource_type = 'vpc'
25
+ end
26
+ end
27
+
28
+ def attached_to_eni?
29
+ resource_type.eql?('eni') ? true : false
30
+ end
31
+
32
+ def attached_to_subnet?
33
+ resource_type.eql?('subnet') ? true : false
34
+ end
35
+
36
+ def attached_to_vpc?
37
+ resource_type.eql?('vpc') ? true : false
38
+ end
39
+
40
+ attr_reader :log_group_name, :resource_id, :flow_log_id
41
+
42
+ private
43
+
44
+ def validate_params(raw_params)
45
+ validated_params = check_resource_param_names(
46
+ raw_params: raw_params,
47
+ allowed_params: [:flow_log_id, :subnet_id, :vpc_id],
48
+ allowed_scalar_name: :flow_log_id,
49
+ allowed_scalar_type: String,
50
+ )
51
+
52
+ if validated_params.empty?
53
+ raise ArgumentError,
54
+ 'aws_flow_log requires a parameter: flow_log_id, subnet_id, or vpc_id'
55
+ end
56
+
57
+ validated_params
58
+ end
59
+
60
+ def fetch_from_api
61
+ backend = BackendFactory.create(inspec_runner)
62
+
63
+ resp = backend.describe_flow_logs(filter_args)
64
+ flow_log = resp.to_h[:flow_logs].first
65
+ @exists = !flow_log.nil?
66
+ unless flow_log.nil?
67
+ @log_group_name = flow_log[:log_group_name]
68
+ @resource_id = flow_log[:resource_id]
69
+ @flow_log_id = flow_log[:flow_log_id]
70
+ end
71
+ end
72
+
73
+ def filter_args
74
+ if @flow_log_id
75
+ { filter: [{ name: 'flow-log-id', values: [@flow_log_id] }] }
76
+ elsif @subnet_id || @vpc_id
77
+ filter = @subnet_id || @vpc_id
78
+ { filter: [{ name: 'resource-id', values: [filter] }] }
79
+ end
80
+ end
81
+
82
+ def id
83
+ return @flow_log_id if @flow_log_id
84
+ return @subnet_id if @subnet_id
85
+ return @vpc_id if @vpc_id
86
+ end
87
+
88
+ def backend
89
+ BackendFactory.create(inspec_runner)
90
+ end
91
+
92
+ class Backend
93
+ class AwsClientApi < AwsBackendBase
94
+ AwsFlowLog::BackendFactory.set_default_backend(self)
95
+ self.aws_client_class = Aws::EC2::Client
96
+
97
+ def describe_flow_logs(query)
98
+ aws_service_client.describe_flow_logs(query)
99
+ end
100
+ end
101
+ end
102
+ end
@@ -19,8 +19,7 @@ class AwsIamGroups < Inspec.resource(1)
19
19
 
20
20
  # Underlying FilterTable implementation.
21
21
  filter = FilterTable.create
22
- filter.add_accessor(:entries)
23
- .add(:exists?) { |x| !x.entries.empty? }
22
+ filter.add(:group_names, field: :group_name)
24
23
  filter.connect(self, :table)
25
24
 
26
25
  def to_s
@@ -23,27 +23,73 @@ class AwsIamUsers < Inspec.resource(1)
23
23
 
24
24
  include AwsPluralResourceMixin
25
25
 
26
+ def self.lazy_get_login_profile(row, _criterion, table)
27
+ backend = BackendFactory.create(table.resource.inspec_runner)
28
+ begin
29
+ _login_profile = backend.get_login_profile(user_name: row[:user_name])
30
+ row[:has_console_password] = true
31
+ rescue Aws::IAM::Errors::NoSuchEntity
32
+ row[:has_console_password] = false
33
+ end
34
+ row[:has_console_password?] = row[:has_console_password]
35
+ end
36
+
37
+ def self.lazy_list_mfa_devices(row, _criterion, table)
38
+ backend = BackendFactory.create(table.resource.inspec_runner)
39
+ begin
40
+ aws_mfa_devices = backend.list_mfa_devices(user_name: row[:user_name])
41
+ row[:has_mfa_enabled] = !aws_mfa_devices.mfa_devices.empty?
42
+ rescue Aws::IAM::Errors::NoSuchEntity
43
+ row[:has_mfa_enabled] = false
44
+ end
45
+ row[:has_mfa_enabled?] = row[:has_mfa_enabled]
46
+ end
47
+
48
+ def self.lazy_list_user_policies(row, _criterion, table)
49
+ backend = BackendFactory.create(table.resource.inspec_runner)
50
+ row[:inline_policy_names] = backend.list_user_policies(user_name: row[:user_name]).policy_names
51
+ row[:has_inline_policies] = !row[:inline_policy_names].empty?
52
+ row[:has_inline_policies?] = row[:has_inline_policies]
53
+ end
54
+
55
+ def self.lazy_list_attached_policies(row, _criterion, table)
56
+ backend = BackendFactory.create(table.resource.inspec_runner)
57
+ attached_policies = backend.list_attached_user_policies(user_name: row[:user_name]).attached_policies
58
+ row[:has_attached_policies] = !attached_policies.empty?
59
+ row[:has_attached_policies?] = row[:has_attached_policies]
60
+ row[:attached_policy_names] = attached_policies.map { |p| p[:policy_name] }
61
+ row[:attached_policy_arns] = attached_policies.map { |p| p[:policy_arn] }
62
+ end
63
+
26
64
  filter = FilterTable.create
27
65
  filter.add_accessor(:where)
28
66
  .add_accessor(:entries)
29
- .add(:exists?) { |x| !x.entries.empty? }
30
- .add(:has_mfa_enabled?, field: :has_mfa_enabled)
31
- .add(:has_console_password?, field: :has_console_password)
32
- .add(:has_inline_policies?, field: :has_inline_policies)
33
- .add(:has_attached_policies?, field: :has_attached_policies)
67
+ # Summary methods
68
+ filter.add(:exists?) { |table| !table.params.empty? }
69
+ .add(:count) { |table| table.params.count }
70
+
71
+ # These are included on the initial fetch
72
+ filter.add(:usernames, field: :user_name)
73
+ .add(:username) { |res| res.entries.map { |row| row[:user_name] } } # We should deprecate this; plural resources get plural properties
34
74
  .add(:password_ever_used?, field: :password_ever_used?)
35
75
  .add(:password_never_used?, field: :password_never_used?)
36
76
  .add(:password_last_used_days_ago, field: :password_last_used_days_ago)
37
- .add(:usernames, field: :user_name)
38
- .add(:username) { |res| res.entries.map { |row| row[:user_name] } } # We should deprecate this; plural resources get plural properties
39
- # Next three are needed to declare fields for use by the de-duped set
40
- filter.add(:dupe_inline_policy_names, field: :inline_policy_names_source)
41
- .add(:dupe_attached_policy_names, field: :attached_policy_names_source)
42
- .add(:dupe_attached_policy_arns, field: :attached_policy_arns_source)
43
- # These three are now able to access the above three in .entries
44
- filter.add(:inline_policy_names) { |obj| obj.dupe_inline_policy_names.flatten.uniq }
45
- .add(:attached_policy_names) { |obj| obj.dupe_attached_policy_names.flatten.uniq }
46
- .add(:attached_policy_arns) { |obj| obj.dupe_attached_policy_arns.flatten.uniq }
77
+
78
+ # Remaining properties / criteria are handled lazily, grouped by fetcher
79
+ filter.add(:has_console_password?, field: :has_console_password?, lazy: method(:lazy_get_login_profile))
80
+ .add(:has_console_password, field: :has_console_password, lazy: method(:lazy_get_login_profile))
81
+
82
+ filter.add(:has_mfa_enabled?, field: :has_mfa_enabled?, lazy: method(:lazy_list_mfa_devices))
83
+ .add(:has_mfa_enabled, field: :has_mfa_enabled, lazy: method(:lazy_list_mfa_devices))
84
+
85
+ filter.add(:has_inline_policies?, field: :has_inline_policies?, lazy: method(:lazy_list_user_policies))
86
+ .add(:has_inline_policies, field: :has_inline_policies, lazy: method(:lazy_list_user_policies))
87
+ .add(:inline_policy_names, field: :inline_policy_names, style: :simple, lazy: method(:lazy_list_user_policies))
88
+
89
+ filter.add(:has_attached_policies?, field: :has_attached_policies?, lazy: method(:lazy_list_attached_policies))
90
+ .add(:has_attached_policies, field: :has_attached_policies, lazy: method(:lazy_list_attached_policies))
91
+ .add(:attached_policy_names, field: :attached_policy_names, style: :simple, lazy: method(:lazy_list_attached_policies))
92
+ .add(:attached_policy_arns, field: :attached_policy_arns, style: :simple, lazy: method(:lazy_list_attached_policies))
47
93
  filter.connect(self, :table)
48
94
 
49
95
  def validate_params(raw_params)
@@ -66,45 +112,17 @@ class AwsIamUsers < Inspec.resource(1)
66
112
  table
67
113
  end
68
114
 
69
- def fetch_from_api # rubocop: disable Metrics/AbcSize
115
+ def fetch_from_api
70
116
  backend = BackendFactory.create(inspec_runner)
71
117
  @table = fetch_from_api_paginated(backend)
72
118
 
73
- # TODO: lazy columns - https://github.com/chef/inspec-aws/issues/100
74
119
  @table.each do |user|
75
- # Some of these throw exceptions to indicate empty results;
76
- # others return empty arrays
77
- begin
78
- _login_profile = backend.get_login_profile(user_name: user[:user_name])
79
- user[:has_console_password] = true
80
- rescue Aws::IAM::Errors::NoSuchEntity
81
- user[:has_console_password] = false
82
- end
83
- user[:has_console_password?] = user[:has_console_password]
84
-
85
- begin
86
- aws_mfa_devices = backend.list_mfa_devices(user_name: user[:user_name])
87
- user[:has_mfa_enabled] = !aws_mfa_devices.mfa_devices.empty?
88
- rescue Aws::IAM::Errors::NoSuchEntity
89
- user[:has_mfa_enabled] = false
90
- end
91
- user[:has_mfa_enabled?] = user[:has_mfa_enabled]
92
-
93
- user[:inline_policy_names_source] = backend.list_user_policies(user_name: user[:user_name]).policy_names
94
- user[:has_inline_policies] = !user[:inline_policy_names_source].empty?
95
- user[:has_inline_policies?] = user[:has_inline_policies]
96
-
97
- attached_policies = backend.list_attached_user_policies(user_name: user[:user_name]).attached_policies
98
- user[:has_attached_policies] = !attached_policies.empty?
99
- user[:has_attached_policies?] = user[:has_attached_policies]
100
- user[:attached_policy_names_source] = attached_policies.map { |p| p[:policy_name] }
101
- user[:attached_policy_arns_source] = attached_policies.map { |p| p[:policy_arn] }
102
-
103
120
  password_last_used = user[:password_last_used]
104
121
  user[:password_ever_used?] = !password_last_used.nil?
105
122
  user[:password_never_used?] = password_last_used.nil?
106
- next unless user[:password_ever_used?]
107
- user[:password_last_used_days_ago] = ((Time.now - password_last_used) / (24*60*60)).to_i
123
+ if user[:password_ever_used?]
124
+ user[:password_last_used_days_ago] = ((Time.now - password_last_used) / (24*60*60)).to_i
125
+ end
108
126
  end
109
127
  @table
110
128
  end
data/lib/resources/npm.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # encoding: utf-8
2
2
 
3
+ require 'shellwords'
4
+
3
5
  module Inspec::Resources
4
6
  class NpmPackage < Inspec.resource(1)
5
7
  name 'npm'
@@ -10,17 +12,28 @@ module Inspec::Resources
10
12
  describe npm('bower') do
11
13
  it { should be_installed }
12
14
  end
15
+
16
+ describe npm('tar', path: '/path/to/project') do
17
+ it { should be_installed }
18
+ end
13
19
  "
14
20
 
15
- def initialize(package_name)
21
+ def initialize(package_name, opts = {})
16
22
  @package_name = package_name
23
+ @location = opts[:path]
17
24
  @cache = nil
18
25
  end
19
26
 
20
27
  def info
21
28
  return @info if defined?(@info)
22
29
 
23
- cmd = inspec.command("npm ls -g --json #{@package_name}")
30
+ if @location
31
+ npm = "cd #{Shellwords.escape @location} && npm"
32
+ else
33
+ npm = 'npm -g'
34
+ end
35
+
36
+ cmd = inspec.command("#{npm} ls --json #{@package_name}")
24
37
  @info = {
25
38
  name: @package_name,
26
39
  type: 'npm',
@@ -270,7 +270,7 @@ module Inspec::Resources
270
270
  # Find the package
271
271
  cmd = inspec.command <<-EOF.gsub(/^\s*/, '')
272
272
  Get-ItemProperty (@("#{search_paths.join('", "')}") | Where-Object { Test-Path $_ }) |
273
- Where-Object { $_.DisplayName -like "#{package_name}" -or $_.PSChildName -like "#{package_name}" } |
273
+ Where-Object { $_.DisplayName -match "^\\s*#{package_name}\\s*$" -or $_.PSChildName -match "^\\s*#{package_name}\\s*$" } |
274
274
  Select-Object -Property DisplayName,DisplayVersion | ConvertTo-Json
275
275
  EOF
276
276
 
data/lib/utils/filter.rb CHANGED
@@ -4,7 +4,8 @@
4
4
  # author: Christoph Hartmann
5
5
 
6
6
  module FilterTable
7
- module Show; end
7
+ # This is used as a sentinel value in custom property filtering
8
+ module NoCriteriaProvided; end
8
9
 
9
10
  class ExceptionCatcher
10
11
  def initialize(original_resource, original_exception)
@@ -82,60 +83,139 @@ module FilterTable
82
83
  end
83
84
 
84
85
  class Table
85
- attr_reader :params, :resource
86
- def initialize(resource, params, filters)
87
- @resource = resource
88
- @params = params
89
- @params = [] if @params.nil?
90
- @filters = filters
86
+ attr_reader :raw_data, :resource_instance, :criteria_string
87
+ def initialize(resource_instance, raw_data, criteria_string)
88
+ @resource_instance = resource_instance
89
+ @raw_data = raw_data
90
+ @raw_data = [] if @raw_data.nil?
91
+ @criteria_string = criteria_string
92
+ @populated_lazy_columns = {}
91
93
  end
92
94
 
95
+ # Filter the raw data based on criteria (as method params) or by evaling a
96
+ # block; then construct a new Table of the same class as ourselves,
97
+ # wrapping the filtered data, and return it.
93
98
  def where(conditions = {}, &block)
94
99
  return self if !conditions.is_a?(Hash)
95
100
  return self if conditions.empty? && !block_given?
96
101
 
97
- filters = ''
98
- table = @params
99
- conditions.each do |field, condition|
100
- filters += " #{field} == #{condition.inspect}"
101
- table = filter_lines(table, field, condition)
102
+ # Initialize the details of the new Table.
103
+ new_criteria_string = criteria_string
104
+ filtered_raw_data = raw_data
105
+
106
+ # If we were provided params, interpret them as criteria to be evaluated
107
+ # against the raw data. Criteria are assumed to be hash keys.
108
+ conditions.each do |raw_field_name, desired_value|
109
+ raise(ArgumentError, "'#{raw_field_name}' is not a recognized criterion - expected one of #{list_fields.join(', ')}'") unless field?(raw_field_name)
110
+ populate_lazy_field(raw_field_name, desired_value) if is_field_lazy?(raw_field_name)
111
+ new_criteria_string += " #{raw_field_name} == #{desired_value.inspect}"
112
+ filtered_raw_data = filter_raw_data(filtered_raw_data, raw_field_name, desired_value)
102
113
  end
103
114
 
115
+ # If we were given a block, make a special Struct for each row, that has an accessor
116
+ # for each field declared using `register_custom_property`, then instance-eval the block
117
+ # against the struct.
104
118
  if block_given?
105
- table = table.find_all { |e| new_entry(e, '').instance_eval(&block) }
119
+ # Perform the filtering.
120
+ filtered_raw_data = filtered_raw_data.find_all { |row_as_hash| create_eval_context_for_row(row_as_hash, '').instance_eval(&block) }
121
+ # Try to interpret the block for updating the stringification.
106
122
  src = Trace.new
107
- src.instance_eval(&block)
108
- filters += Trace.to_ruby(src)
123
+ # Swallow any exceptions raised here.
124
+ # See https://github.com/chef/inspec/issues/2929
125
+ begin
126
+ src.instance_eval(&block)
127
+ rescue # rubocop: disable Lint/HandleExceptions
128
+ # Yes, robocop, ignoring all exceptions is normally
129
+ # a bad idea. Here, an exception just means we don't
130
+ # understand what was in a `where` block, so we can't
131
+ # meaningfully sytringify it. We still have a decent
132
+ # default stringification.
133
+ end
134
+ new_criteria_string += Trace.to_ruby(src)
109
135
  end
110
136
 
111
- self.class.new(@resource, table, @filters + filters)
137
+ self.class.new(resource, filtered_raw_data, new_criteria_string)
112
138
  end
113
139
 
114
- def new_entry(*_)
140
+ def create_eval_context_for_row(*_)
115
141
  raise "#{self.class} must not be used on its own. It must be inherited "\
116
- 'and the #new_entry method must be implemented. This is an internal '\
142
+ 'and the #create_eval_context_for_row method must be implemented. This is an internal '\
117
143
  'error and should not happen.'
118
144
  end
119
145
 
146
+ def resource
147
+ resource_instance
148
+ end
149
+
150
+ def params
151
+ # TODO: consider deprecating
152
+ raw_data
153
+ end
154
+
120
155
  def entries
121
- f = @resource.to_s + @filters.to_s + ' one entry'
122
- @params.map do |line|
123
- new_entry(line, f)
156
+ row_criteria_string = resource.to_s + criteria_string + ' one entry'
157
+ raw_data.map do |row|
158
+ create_eval_context_for_row(row, row_criteria_string)
124
159
  end
125
160
  end
126
161
 
127
- def get_field(field)
128
- @params.map do |line|
129
- line[field]
162
+ def get_column_values(field)
163
+ raw_data.map do |row|
164
+ row[field]
130
165
  end
131
166
  end
132
167
 
168
+ def list_fields
169
+ @__fields_in_raw_data ||= raw_data.reduce([]) do |fields, row|
170
+ fields.concat(row.keys).uniq
171
+ end
172
+ end
173
+
174
+ def field?(proposed_field)
175
+ # Currently we only know about a field if it is present in a at least one row of the raw data.
176
+ # If we have no rows in the raw data, assume all fields are acceptable (and rely on failing to match on value, nil)
177
+ return true if raw_data.empty?
178
+ list_fields.include?(proposed_field) || is_field_lazy?(proposed_field)
179
+ end
180
+
133
181
  def to_s
134
- @resource.to_s + @filters
182
+ resource.to_s + criteria_string
135
183
  end
136
184
 
137
185
  alias inspect to_s
138
186
 
187
+ def populate_lazy_field(field_name, criterion)
188
+ return unless is_field_lazy?(field_name)
189
+ return if field_populated?(field_name)
190
+ raw_data.each do |row|
191
+ next if row.key?(field_name) # skip row if pre-existing data is present
192
+ callback_for_lazy_field(field_name).call(row, criterion, self)
193
+ end
194
+ mark_lazy_field_populated(field_name)
195
+ end
196
+
197
+ def is_field_lazy?(sought_field_name)
198
+ custom_properties_schema.values.any? do |property_struct|
199
+ sought_field_name == property_struct.field_name && \
200
+ property_struct.opts[:lazy]
201
+ end
202
+ end
203
+
204
+ def callback_for_lazy_field(field_name)
205
+ return unless is_field_lazy?(field_name)
206
+ custom_properties_schema.values.find do |property_struct|
207
+ property_struct.field_name == field_name
208
+ end.opts[:lazy]
209
+ end
210
+
211
+ def field_populated?(field_name)
212
+ @populated_lazy_columns[field_name]
213
+ end
214
+
215
+ def mark_lazy_field_populated(field_name)
216
+ @populated_lazy_columns[field_name] = true
217
+ end
218
+
139
219
  private
140
220
 
141
221
  def matches_float(x, y)
@@ -159,111 +239,189 @@ module FilterTable
159
239
  x === y # rubocop:disable Style/CaseEquality
160
240
  end
161
241
 
162
- def filter_lines(table, field, condition)
163
- m = case condition
164
- when Float then method(:matches_float)
165
- when Integer then method(:matches_int)
166
- when Regexp then method(:matches_regex)
167
- else method(:matches)
168
- end
169
-
170
- table.find_all do |line|
171
- next unless line.key?(field)
172
- m.call(line[field], condition)
242
+ def filter_raw_data(current_raw_data, field, desired_value)
243
+ method_ref = case desired_value
244
+ when Float then method(:matches_float)
245
+ when Integer then method(:matches_int)
246
+ when Regexp then method(:matches_regex)
247
+ else method(:matches)
248
+ end
249
+
250
+ current_raw_data.find_all do |row|
251
+ next unless row.key?(field)
252
+ method_ref.call(row[field], desired_value)
173
253
  end
174
254
  end
175
255
  end
176
256
 
177
257
  class Factory
178
- Connector = Struct.new(:field_name, :block, :opts)
258
+ CustomPropertyType = Struct.new(:field_name, :block, :opts)
179
259
 
180
260
  def initialize
181
- @accessors = []
182
- @connectors = {}
183
- @resource = nil
261
+ @filter_methods = [:where, :entries, :raw_data]
262
+ @custom_properties = {}
263
+ register_custom_matcher(:exist?) { |table| !table.raw_data.empty? }
264
+ register_custom_property(:count) { |table| table.raw_data.count }
265
+
266
+ @resource = nil # TODO: this variable is never initialized
184
267
  end
185
268
 
186
- def connect(resource, table_accessor) # rubocop:disable Metrics/AbcSize
187
- # create the table structure
188
- connectors = @connectors
189
- struct_fields = connectors.values.map(&:field_name)
190
- connector_blocks = connectors.map do |method, c|
191
- [method.to_sym, create_connector(c)]
269
+ def install_filter_methods_on_resource(resource_class, raw_data_fetcher_method_name) # rubocop: disable Metrics/AbcSize, Metrics/MethodLength
270
+ # A context in which you can access the fields as accessors
271
+ non_block_struct_fields = @custom_properties.values.reject(&:block).map(&:field_name)
272
+ row_eval_context_type = Struct.new(*non_block_struct_fields.map(&:to_sym)) do
273
+ attr_accessor :criteria_string
274
+ attr_accessor :filter_table
275
+ def to_s
276
+ @criteria_string || super
277
+ end
278
+ end unless non_block_struct_fields.empty?
279
+
280
+ properties_to_define = @custom_properties.map do |method_name, custom_property_structure|
281
+ { method_name: method_name, method_body: create_custom_property_body(custom_property_structure) }
192
282
  end
193
283
 
194
- # the struct to hold single items from the #entries method
195
- entry_struct = Struct.new(*struct_fields.map(&:to_sym)) do
196
- attr_accessor :__filter
197
- def to_s
198
- @__filter || super
284
+ # Define the filter table subclass
285
+ custom_properties = @custom_properties # We need a local var, not an instance var, for a closure below
286
+ table_class = Class.new(Table) {
287
+ # Install each custom property onto the FilterTable subclass
288
+ properties_to_define.each do |property_info|
289
+ define_method property_info[:method_name], &property_info[:method_body]
199
290
  end
200
- end unless struct_fields.empty?
201
291
 
202
- # the main filter table
203
- table = Class.new(Table) {
204
- connector_blocks.each do |x|
205
- define_method x[0], &x[1]
292
+ define_method :custom_properties_schema do
293
+ custom_properties
206
294
  end
207
295
 
208
- define_method :new_entry do |hashmap, filter = ''|
209
- return entry_struct.new if hashmap.nil?
210
- res = entry_struct.new(*struct_fields.map { |x| hashmap[x] })
211
- res.__filter = filter
212
- res
296
+ # Install a method that can wrap all the fields into a context with accessors
297
+ define_method :create_eval_context_for_row do |row_as_hash, criteria_string = ''|
298
+ return row_eval_context_type.new if row_as_hash.nil?
299
+ context = row_eval_context_type.new(*non_block_struct_fields.map { |field| row_as_hash[field] })
300
+ context.criteria_string = criteria_string
301
+ context.filter_table = self
302
+ context
213
303
  end
214
304
  }
215
305
 
306
+ # Now that the table class is defined and the row eval context struct is defined,
307
+ # extend the row eval context struct to support triggering population of lazy fields
308
+ # in where blocks. To do that, we'll need a reference to the table (which
309
+ # knows which fields are populated, and how to populate them) and we'll need to
310
+ # override the getter method for each lazy field, so it will trigger
311
+ # population if needed. Keep in mind we don't have to adjust the constructor
312
+ # args of the row struct; also the Struct class will already have provided
313
+ # a setter for each field.
314
+ @custom_properties.values.each do |property_info|
315
+ next unless property_info.opts[:lazy]
316
+ field_name = property_info.field_name.to_sym
317
+ row_eval_context_type.send(:define_method, field_name) do
318
+ unless filter_table.field_populated?(field_name)
319
+ filter_table.populate_lazy_field(field_name, NoCriteriaProvided) # No access to criteria here
320
+ # OK, the underlying raw data has the value in the first row
321
+ # (because we would trigger population only on the first row)
322
+ # We could just return the value, but we need to set it on this Struct in case it is referenced multiple times
323
+ # in the where block.
324
+ self[field_name] = filter_table.raw_data[0][field_name]
325
+ end
326
+ # Now return the value using the Struct getter, whether newly populated or not
327
+ self[field_name]
328
+ end
329
+ end
330
+
216
331
  # Define all access methods with the parent resource
217
332
  # These methods will be configured to return an `ExceptionCatcher` object
218
333
  # that will always return the original exception, but only when called
219
334
  # upon. This will allow method chains in `describe` statements to pass the
220
335
  # `instance_eval` when loaded and only throw-and-catch the exception when
221
336
  # the tests are run.
222
- accessors = @accessors + @connectors.keys
223
- accessors.each do |method_name|
224
- resource.send(:define_method, method_name.to_sym) do |*args, &block|
337
+ methods_to_install_on_resource_class = @filter_methods + @custom_properties.keys
338
+ methods_to_install_on_resource_class.each do |method_name|
339
+ resource_class.send(:define_method, method_name.to_sym) do |*args, &block|
225
340
  begin
226
- filter = table.new(self, method(table_accessor).call, ' with')
227
- filter.method(method_name.to_sym).call(*args, &block)
341
+ # self here is the resource instance
342
+ filter_table_instance = table_class.new(self, method(raw_data_fetcher_method_name).call, ' with')
343
+ filter_table_instance.method(method_name.to_sym).call(*args, &block)
228
344
  rescue Inspec::Exceptions::ResourceFailed, Inspec::Exceptions::ResourceSkipped => e
229
- FilterTable::ExceptionCatcher.new(resource, e)
345
+ FilterTable::ExceptionCatcher.new(resource_class, e)
230
346
  end
231
347
  end
232
348
  end
233
349
  end
234
350
 
235
- def add_accessor(method_name)
351
+ alias connect install_filter_methods_on_resource
352
+
353
+ # TODO: This should almost certainly be privatized. Every FilterTable client should get :entries and :where;
354
+ # InSpec core resources do not define anything else, other than azure_generic_resource, which is likely a mis-use.
355
+ def register_filter_method(method_name)
236
356
  if method_name.nil?
237
- throw RuntimeError, "Called filter.add_delegator for resource #{@resource} with method name nil!"
357
+ # TODO: @resource is never initialized
358
+ throw RuntimeError, "Called filter.add_accessor for resource #{@resource} with method name nil!"
359
+ end
360
+ if @filter_methods.include? method_name.to_sym
361
+ # TODO: issue deprecation warning?
362
+ else
363
+ @filter_methods.push(method_name.to_sym)
238
364
  end
239
- @accessors.push(method_name)
240
365
  self
241
366
  end
242
367
 
243
- def add(method_name, opts = {}, &block)
244
- if method_name.nil?
368
+ alias add_accessor register_filter_method
369
+
370
+ def register_custom_property(property_name, opts = {}, &property_implementation)
371
+ if property_name.nil?
372
+ # TODO: @resource is never initialized
245
373
  throw RuntimeError, "Called filter.add for resource #{@resource} with method name nil!"
246
374
  end
247
375
 
248
- @connectors[method_name.to_sym] =
249
- Connector.new(opts[:field] || method_name, block, opts)
376
+ if @custom_properties.key?(property_name.to_sym)
377
+ # TODO: issue deprecation warning?
378
+ else
379
+ @custom_properties[property_name.to_sym] =
380
+ CustomPropertyType.new(opts[:field] || property_name, property_implementation, opts)
381
+ end
250
382
  self
251
383
  end
252
384
 
253
- private
385
+ alias add register_custom_property
386
+ alias register_column register_custom_property
387
+ alias register_custom_matcher register_custom_property
254
388
 
255
- def create_connector(c)
256
- return ->(cond = Show) { c.block.call(self, cond) } if !c.block.nil?
389
+ private
257
390
 
258
- lambda { |condition = Show, &cond_block|
259
- if condition == Show && !block_given?
260
- r = where(nil).get_field(c.field_name)
261
- r = r.flatten.uniq.compact if c.opts[:style] == :simple
262
- r
263
- else
264
- where({ c.field_name => condition }, &cond_block)
391
+ # This provides the implementation for methods requested using
392
+ # register_custom_property(:some_method_name, opts, &block)
393
+ # Some usage in the wild involves people passing a desired value to the generated method, like:
394
+ # things.ids(23)
395
+ # I'm calling this the 'filter_criterion_value'. I speculate that a default value is
396
+ # provided here so that users can meaningfully query for nil.
397
+ def create_custom_property_body(custom_property_struct)
398
+ if !custom_property_struct.block.nil?
399
+ # If the custom method provided its own block, rely on it.
400
+ lambda do |filter_criteria_value = NoCriteriaProvided|
401
+ # Here self is an instance of the FilterTable subclass that wraps the raw data.
402
+ # Call the block with two args - the table instance, and any filter criteria value.
403
+ custom_property_struct.block.call(self, filter_criteria_value)
265
404
  end
266
- }
405
+ else
406
+ # No block definition, so the property was registered using (field: :some_field)
407
+ # This does however support passing a block to this method, and filtering using it, like Enumerable#select.
408
+ lambda do |filter_criteria_value = NoCriteriaProvided, &cond_block|
409
+ if filter_criteria_value == NoCriteriaProvided && !block_given?
410
+ # No second-order block given. Just return an array of the values in the selected column.
411
+ result = where(nil)
412
+ if custom_property_struct.opts[:lazy]
413
+ result.populate_lazy_field(custom_property_struct.field_name, filter_criteria_value)
414
+ end
415
+ result = where(nil).get_column_values(custom_property_struct.field_name) # TODO: the where(nil). is likely unneeded
416
+ result = result.flatten.uniq.compact if custom_property_struct.opts[:style] == :simple
417
+ result
418
+ else
419
+ # A secondary block was provided. Rely on where() to execute the block, while also filtering on any single value
420
+ # Suspected bug: if filter_criteria_value == NoCriteriaProvided, this is unlikely to match - see hash condition handling in where() above.
421
+ where(custom_property_struct.field_name => filter_criteria_value, &cond_block)
422
+ end
423
+ end
424
+ end
267
425
  end
268
426
  end
269
427