openstack_taster 1.0.1
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.
- checksums.yaml +7 -0
- data/bin/openstack_taster +100 -0
- data/lib/openstack_taster.rb +431 -0
- data/tests/controls/security_test.rb +51 -0
- data/tests/inspec.yml +7 -0
- metadata +122 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: d197f40b701de60109b9171f250962922dc2f860
|
4
|
+
data.tar.gz: 70c9e7da35b886369ae89a4dece5b1e50260235a
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: d2868e2dae6f66633d73f6ddb54edf80b905e3c35b6b08121128c77729ce597a5e64d7f76c08d1f26e5cb56f9140499e76de3e44ea2e8904b03693e903bbf455
|
7
|
+
data.tar.gz: f391b2d1d487bd2e4fedd8ba9aaa49f7599b3769ff71d0c120fdb9f93b9a63426deb8c8da841bee5d302cb1636c9d080f13bdee7e684bf7f70bcdda343744901
|
@@ -0,0 +1,100 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require 'optparse'
|
5
|
+
|
6
|
+
suites = {
|
7
|
+
'security' => 'Runs the security test suite',
|
8
|
+
'volumes' => 'Runs the volume test suite'
|
9
|
+
}
|
10
|
+
settings = {}
|
11
|
+
|
12
|
+
ARGV << '-h' if ARGV.empty?
|
13
|
+
|
14
|
+
parser = OptionParser.new do |opts|
|
15
|
+
opts.banner = "Usage: openstack_taster <image_name> {suite_name} [--create-snapshot]\n" \
|
16
|
+
" or: openstack_taster <image_name> [--create-snapshot]\n" \
|
17
|
+
' or: openstack_taster <option>'
|
18
|
+
opts.separator('')
|
19
|
+
opts.separator('Available Arguments:')
|
20
|
+
opts.on('-c', '--create-snapshot', 'Create snapshot upon test failure.') do
|
21
|
+
settings[:create_snapshot] = true
|
22
|
+
end
|
23
|
+
opts.on('-h', '--help', 'Print usage information.') do
|
24
|
+
puts opts
|
25
|
+
settings[:exit] = true
|
26
|
+
end
|
27
|
+
opts.separator('')
|
28
|
+
opts.separator('Test Suites:')
|
29
|
+
suites.each do |suite, desc|
|
30
|
+
opts.separator(" #{suite}\t\t#{desc}")
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
begin
|
35
|
+
params = parser.parse!
|
36
|
+
rescue OptionParser::InvalidOption => io
|
37
|
+
puts io.message
|
38
|
+
puts
|
39
|
+
puts parser
|
40
|
+
exit 1
|
41
|
+
rescue StandardError => e
|
42
|
+
puts 'Argument parsing failed:'
|
43
|
+
puts e.message
|
44
|
+
puts e.backtrace
|
45
|
+
exit 1
|
46
|
+
end
|
47
|
+
|
48
|
+
exit if settings[:exit] # exit inside OptionParser causes problems.
|
49
|
+
|
50
|
+
begin
|
51
|
+
case params.length
|
52
|
+
when 1
|
53
|
+
image_name = params[0]
|
54
|
+
suites.each_key { |suite| settings[suite.to_sym] = true }
|
55
|
+
when 2
|
56
|
+
raise "#{params[1]} is not a test suite!" unless suites.include? params[1]
|
57
|
+
image_name = params[0]
|
58
|
+
settings[params[1].to_sym] = true
|
59
|
+
else
|
60
|
+
raise 'Incorrect format!'
|
61
|
+
end
|
62
|
+
rescue StandardError => e
|
63
|
+
puts e.message
|
64
|
+
puts
|
65
|
+
puts parser
|
66
|
+
exit(1)
|
67
|
+
end
|
68
|
+
|
69
|
+
require 'fog/openstack'
|
70
|
+
require 'openstack_taster'
|
71
|
+
|
72
|
+
auth_url = String.new(ENV['OS_AUTH_URL'])
|
73
|
+
auth_url << '/tokens' unless auth_url.end_with?('tokens')
|
74
|
+
auth_url.freeze
|
75
|
+
|
76
|
+
OPENSTACK_CREDS = {
|
77
|
+
openstack_auth_url: auth_url,
|
78
|
+
openstack_username: ENV['OS_USERNAME'],
|
79
|
+
openstack_tenant: ENV['OS_TENANT_NAME'],
|
80
|
+
openstack_api_key: ENV['OS_PASSWORD']
|
81
|
+
}.freeze
|
82
|
+
|
83
|
+
SSH_KEYS = {
|
84
|
+
keypair: ENV['OS_SSH_KEYPAIR'],
|
85
|
+
private_key: ENV['OS_PRIVATE_SSH_KEY'],
|
86
|
+
public_key: ENV['OS_PUBLIC_SSH_KEY'] # REVIEW
|
87
|
+
}.freeze
|
88
|
+
|
89
|
+
controller_host = auth_url.split(':')[1].delete('//')
|
90
|
+
LOG_DIR = "logs/#{controller_host}"
|
91
|
+
|
92
|
+
compute = Fog::Compute::OpenStack.new(OPENSTACK_CREDS)
|
93
|
+
volume = Fog::Volume::OpenStack.new(OPENSTACK_CREDS)
|
94
|
+
image = Fog::Image::OpenStack.new(OPENSTACK_CREDS)
|
95
|
+
network = Fog::Network::OpenStack.new(OPENSTACK_CREDS)
|
96
|
+
|
97
|
+
exit OpenStackTaster.new(
|
98
|
+
compute, volume, image, network,
|
99
|
+
SSH_KEYS, LOG_DIR
|
100
|
+
).taste(image_name, settings)
|
@@ -0,0 +1,431 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'fileutils'
|
4
|
+
require 'date'
|
5
|
+
require 'excon'
|
6
|
+
require 'net/ssh'
|
7
|
+
require 'pry'
|
8
|
+
require 'inspec'
|
9
|
+
|
10
|
+
# @author Andrew Tolvstad, Samarendra Hedaoo, Cody Holliday
|
11
|
+
class OpenStackTaster
|
12
|
+
INSTANCE_FLAVOR_NAME = 'm1.small'
|
13
|
+
INSTANCE_NETWORK_NAME = 'public'
|
14
|
+
INSTANCE_NAME_PREFIX = 'taster'
|
15
|
+
INSTANCE_VOLUME_MOUNT_POINT = '/mnt/taster_volume'
|
16
|
+
|
17
|
+
VOLUME_TEST_FILE_NAME = 'info'
|
18
|
+
VOLUME_TEST_FILE_CONTENTS = nil # Contents would be something like 'test-vol-1 on openpower8.osuosl.bak'
|
19
|
+
TIMEOUT_INSTANCE_CREATE = 20
|
20
|
+
TIMEOUT_VOLUME_ATTACH = 10
|
21
|
+
TIMEOUT_VOLUME_PERSIST = 20
|
22
|
+
TIMEOUT_INSTANCE_TO_BE_CREATED = 20
|
23
|
+
TIMEOUT_INSTANCE_STARTUP = 30
|
24
|
+
TIMEOUT_SSH_RETRY = 15
|
25
|
+
|
26
|
+
MAX_SSH_RETRY = 3
|
27
|
+
|
28
|
+
TIME_SLUG_FORMAT = '%Y%m%d_%H%M%S'
|
29
|
+
|
30
|
+
def initialize(
|
31
|
+
compute_service,
|
32
|
+
volume_service,
|
33
|
+
image_service,
|
34
|
+
network_service,
|
35
|
+
ssh_keys,
|
36
|
+
log_dir
|
37
|
+
)
|
38
|
+
@compute_service = compute_service
|
39
|
+
@volume_service = volume_service
|
40
|
+
@image_service = image_service
|
41
|
+
@network_service = network_service
|
42
|
+
|
43
|
+
@volumes = @volume_service.volumes
|
44
|
+
|
45
|
+
@ssh_keypair = ssh_keys[:keypair]
|
46
|
+
@ssh_private_key = ssh_keys[:private_key]
|
47
|
+
@ssh_public_key = ssh_keys[:public_key] # REVIEW
|
48
|
+
|
49
|
+
@session_id = object_id
|
50
|
+
@log_dir = log_dir + "/#{@session_id}"
|
51
|
+
|
52
|
+
@instance_flavor = @compute_service.flavors
|
53
|
+
.select { |flavor| flavor.name == INSTANCE_FLAVOR_NAME }.first
|
54
|
+
@instance_network = @network_service.networks
|
55
|
+
.select { |network| network.name == INSTANCE_NETWORK_NAME }.first
|
56
|
+
end
|
57
|
+
|
58
|
+
# Taste a specified image
|
59
|
+
# @param image_name [String] The name on OpenStack of the image to be tested.
|
60
|
+
# @param settings [Hash] A hash of settings to enable and disable tests, snapshot creation upon failure.
|
61
|
+
# @return [Boolean] success or failure of tests on image.
|
62
|
+
# @note The testing section could be further streamlined by:
|
63
|
+
# creating a naming standard for test functions (i.e. taste_<name>)
|
64
|
+
# limiting the parameters of each test to be: instance, distro_username
|
65
|
+
# Adding a 'suites' subhash to the settings hash
|
66
|
+
# Then that subhash can be iterated over, use eval to call each function,
|
67
|
+
# appending the suite name to 'taste_' for the function name
|
68
|
+
# and passing the standardized parameters
|
69
|
+
# @todo Reduce Percieved and Cyclomatic complexity
|
70
|
+
# @todo Images over compute service is deprecated
|
71
|
+
def taste(image_name, settings)
|
72
|
+
image = @compute_service.images
|
73
|
+
.select { |i| i.name == image_name }.first
|
74
|
+
|
75
|
+
abort("#{image_name} is not an available image.") if image.nil?
|
76
|
+
|
77
|
+
distro_user_name = image.name.downcase.gsub(/[^a-z].*$/, '') # truncate downcased name at first non-alpha char
|
78
|
+
distro_arch = image.name.downcase.slice(-2, 2)
|
79
|
+
instance_name = format(
|
80
|
+
'%s-%s-%s-%s',
|
81
|
+
INSTANCE_NAME_PREFIX,
|
82
|
+
Time.new.strftime(TIME_SLUG_FORMAT),
|
83
|
+
distro_user_name,
|
84
|
+
distro_arch
|
85
|
+
)
|
86
|
+
|
87
|
+
FileUtils.mkdir_p(@log_dir) unless Dir.exist?(@log_dir)
|
88
|
+
|
89
|
+
instance_logger = Logger.new("#{@log_dir}/#{instance_name}.log")
|
90
|
+
|
91
|
+
error_log(
|
92
|
+
instance_logger,
|
93
|
+
'info',
|
94
|
+
"Tasting #{image.name} as '#{instance_name}' with username '#{distro_user_name}'.\nBuilding...",
|
95
|
+
true
|
96
|
+
)
|
97
|
+
|
98
|
+
instance = @compute_service.servers.create(
|
99
|
+
name: instance_name,
|
100
|
+
flavor_ref: @instance_flavor.id,
|
101
|
+
image_ref: image.id,
|
102
|
+
nics: [{ net_id: @instance_network.id }],
|
103
|
+
key_name: @ssh_keypair
|
104
|
+
)
|
105
|
+
|
106
|
+
if instance.nil?
|
107
|
+
error_log(instance_logger, 'error', 'Failed to create instance.', true)
|
108
|
+
return false
|
109
|
+
end
|
110
|
+
|
111
|
+
instance.class.send(:attr_accessor, 'logger')
|
112
|
+
|
113
|
+
instance.logger = instance_logger
|
114
|
+
|
115
|
+
instance.wait_for(TIMEOUT_INSTANCE_TO_BE_CREATED) { ready? }
|
116
|
+
|
117
|
+
error_log(instance.logger, 'info', "Sleeping #{TIMEOUT_INSTANCE_STARTUP} seconds for OS startup...", true)
|
118
|
+
sleep TIMEOUT_INSTANCE_STARTUP
|
119
|
+
|
120
|
+
error_log(instance.logger, 'info', "Testing for instance '#{instance.id}'.", true)
|
121
|
+
|
122
|
+
# Run tests
|
123
|
+
return_values = []
|
124
|
+
return_values.push taste_security(instance, distro_user_name) if settings[:security]
|
125
|
+
return_values.push taste_volumes(instance, distro_user_name) if settings[:volumes]
|
126
|
+
|
127
|
+
if settings[:create_snapshot] && !return_values.all?
|
128
|
+
error_log(instance.logger, 'info', "Tests failed for instance '#{instance.id}'. Creating image...", true)
|
129
|
+
create_image(instance) # Create image here since it is destroyed before scope returns to taste function
|
130
|
+
end
|
131
|
+
return return_values.all?
|
132
|
+
rescue Fog::Errors::TimeoutError
|
133
|
+
puts 'Instance creation timed out.'
|
134
|
+
error_log(instance.logger, 'error', "Instance fault: #{instance.fault}")
|
135
|
+
return false
|
136
|
+
rescue Interrupt
|
137
|
+
puts "\nCaught interrupt"
|
138
|
+
puts "Exiting session #{@session_id}"
|
139
|
+
raise
|
140
|
+
ensure
|
141
|
+
if instance
|
142
|
+
puts "Destroying instance for session #{@session_id}.\n\n"
|
143
|
+
instance.destroy
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
# Runs the security test suite using inspec
|
148
|
+
# @param instance [Fog::Image::OpenStack::Image] The instance to test.
|
149
|
+
# @param username [String] The username to use when logging into the instance.
|
150
|
+
# @return [Boolean] Whether or not the image passed hte security tests.
|
151
|
+
# @todo Don't crash when connection refused.
|
152
|
+
def taste_security(instance, username)
|
153
|
+
opts = {
|
154
|
+
'backend' => 'ssh',
|
155
|
+
'host' => instance.addresses['public'].first['addr'],
|
156
|
+
'port' => 22,
|
157
|
+
'user' => username,
|
158
|
+
'keys_only' => true,
|
159
|
+
'key_files' => @ssh_private_key,
|
160
|
+
'logger' => instance.logger
|
161
|
+
}
|
162
|
+
|
163
|
+
tries = 0
|
164
|
+
|
165
|
+
begin
|
166
|
+
runner = Inspec::Runner.new(opts)
|
167
|
+
runner.add_target(File.dirname(__FILE__) + '/../tests')
|
168
|
+
runner.run
|
169
|
+
rescue RuntimeError => e
|
170
|
+
puts "Encountered error \"#{e.message}\" while testing the instance."
|
171
|
+
if tries < MAX_SSH_RETRY
|
172
|
+
tries += 1
|
173
|
+
puts "Initiating SSH attempt #{tries} in #{TIMEOUT_SSH_RETRY} seconds"
|
174
|
+
sleep TIMEOUT_SSH_RETRY
|
175
|
+
retry
|
176
|
+
end
|
177
|
+
error_log(instance.logger, 'error', e.backtrace, false, 'Inspec Runner')
|
178
|
+
error_log(instance.logger, 'error', e.message, false, 'Inspec Runner')
|
179
|
+
return true
|
180
|
+
rescue StandardError => e
|
181
|
+
puts "Encountered error \"#{e.message}\". Aborting test."
|
182
|
+
return true
|
183
|
+
end
|
184
|
+
|
185
|
+
error_log(
|
186
|
+
instance.logger,
|
187
|
+
'info',
|
188
|
+
"Inspec Test Results\n" +
|
189
|
+
runner.report[:controls].map do |test|
|
190
|
+
"#{test[:status].upcase}: #{test[:code_desc]}\n#{test[:message]}"
|
191
|
+
end.join("\n")
|
192
|
+
)
|
193
|
+
|
194
|
+
if runner.report[:controls].any? { |test| test[:status] == 'failed' }
|
195
|
+
error_log(instance.logger, 'warn', 'Image failed security test suite')
|
196
|
+
return false
|
197
|
+
end
|
198
|
+
true
|
199
|
+
end
|
200
|
+
|
201
|
+
# Write an error message to the log and optionally stdout.
|
202
|
+
# @param logger [Logger] the logger used to record the message.
|
203
|
+
# @param level [String] the level to use when logging.
|
204
|
+
# @param message [String] the message to write
|
205
|
+
# @param dup_stdout [Boolean] whether or not to print the message to stdout
|
206
|
+
# @param context [String] the context of the message to be logged. i.e. SSH, Inspec, etc.
|
207
|
+
def error_log(logger, level, message, dup_stdout = false, context = nil)
|
208
|
+
puts message if dup_stdout
|
209
|
+
|
210
|
+
begin
|
211
|
+
logger.add(Logger.const_get(level.upcase), message, context)
|
212
|
+
rescue NameError
|
213
|
+
puts
|
214
|
+
puts "\e[31m#{level} is not a severity. Make sure that you use the correct string for logging severity!\e[0m"
|
215
|
+
puts
|
216
|
+
logger.error('Taster Source Code') { "#{level} is not a logging severity name. Defaulting to INFO." }
|
217
|
+
logger.info(context) { message }
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
# Get the name of the image from which an instance was created.
|
222
|
+
# @param instance [Fog::Compute::OpenStack::Server] the instance to query
|
223
|
+
# @return [String] the name of the image
|
224
|
+
def get_image_name(instance)
|
225
|
+
@image_service
|
226
|
+
.get_image_by_id(instance.image['id'])
|
227
|
+
.body['name']
|
228
|
+
end
|
229
|
+
|
230
|
+
# Create an image of an instance.
|
231
|
+
# @note This method blocks until snapshot creation is complete on the server.
|
232
|
+
# @param instance [Fog::Compute::OpenStack::Server] the instance to query
|
233
|
+
# @return [Fog::Image::OpenStack::Image] the generated image
|
234
|
+
def create_image(instance)
|
235
|
+
image_name = [
|
236
|
+
instance.name,
|
237
|
+
get_image_name(instance)
|
238
|
+
].join('_')
|
239
|
+
|
240
|
+
response = instance.create_image(image_name)
|
241
|
+
image_id = response.body['image']['id']
|
242
|
+
|
243
|
+
@image_service.images
|
244
|
+
.find_by_id(image_id)
|
245
|
+
.wait_for { status == 'active' }
|
246
|
+
end
|
247
|
+
|
248
|
+
# Run the set of tests for each available volume on an instance.
|
249
|
+
# @param instance [Fog::Compute::OpenStack::Server] the instance to query
|
250
|
+
# @param username [String] the username to use when logging into the instance
|
251
|
+
# @return [Boolean] Whether or not the tests succeeded
|
252
|
+
def taste_volumes(instance, username)
|
253
|
+
mount_failures = @volumes.reject do |volume|
|
254
|
+
if volume.attachments.any?
|
255
|
+
error_log(instance.logger, 'info', "Volume '#{volume.name}' is already in an attached state; skipping.", true)
|
256
|
+
next
|
257
|
+
end
|
258
|
+
|
259
|
+
unless volume_attach?(instance, volume)
|
260
|
+
error_log(instance.logger, 'error', "Volume '#{volume.name}' failed to attach.", true)
|
261
|
+
next
|
262
|
+
end
|
263
|
+
|
264
|
+
volume_mount_unmount?(instance, username, volume)
|
265
|
+
end
|
266
|
+
|
267
|
+
detach_failures = @volumes.reject do |volume|
|
268
|
+
volume_detach?(instance, volume)
|
269
|
+
end
|
270
|
+
|
271
|
+
if mount_failures.empty? && detach_failures.empty?
|
272
|
+
error_log(instance.logger, 'info', "\nEncountered 0 failures.", true)
|
273
|
+
true
|
274
|
+
else
|
275
|
+
error_log(
|
276
|
+
instance.logger,
|
277
|
+
'error',
|
278
|
+
"\nEncountered #{mount_failures.count} mount failures and #{detach_failures.count} detach failures.",
|
279
|
+
true
|
280
|
+
)
|
281
|
+
error_log(instance.logger, 'error', "\nEncountered failures.", true)
|
282
|
+
false
|
283
|
+
end
|
284
|
+
end
|
285
|
+
|
286
|
+
# A helper method to execute a series of commands remotely on an instance. This helper
|
287
|
+
# passes its block directly to `Net::SSH#start()`.
|
288
|
+
# @param instance [Fog::Compute::OpenStack::Server] the instance on which to run the commands
|
289
|
+
# @param username [String] the username to use when logging into the instance
|
290
|
+
# @todo Don't crash when connection refused.
|
291
|
+
def with_ssh(instance, username, &block)
|
292
|
+
tries = 0
|
293
|
+
instance.logger.progname = 'SSH'
|
294
|
+
begin
|
295
|
+
Net::SSH.start(
|
296
|
+
instance.addresses['public'].first['addr'],
|
297
|
+
username,
|
298
|
+
verbose: :info,
|
299
|
+
paranoid: false,
|
300
|
+
logger: instance.logger,
|
301
|
+
keys: [@ssh_private_key],
|
302
|
+
&block
|
303
|
+
)
|
304
|
+
rescue Errno::ECONNREFUSED => e
|
305
|
+
puts "Encountered #{e.message} while connecting to the instance."
|
306
|
+
if tries < MAX_SSH_RETRY
|
307
|
+
tries += 1
|
308
|
+
puts "Initiating SSH attempt #{tries} in #{TIMEOUT_SSH_RETRY} seconds"
|
309
|
+
sleep TIMEOUT_SSH_RETRY
|
310
|
+
retry
|
311
|
+
end
|
312
|
+
error_log(instance.logger, 'error', e.backtrace, false, 'SSH')
|
313
|
+
error_log(instance.logger, 'error', e.message, false, 'SSH')
|
314
|
+
exit 1
|
315
|
+
end
|
316
|
+
end
|
317
|
+
|
318
|
+
# Test volume attachment for a given instance and volume.
|
319
|
+
# @param instance [Fog::Compute::OpenStack::Server] the instance to which to attach the volume
|
320
|
+
# @param volume [Fog::Volume::OpenStack::Volume] the volume to attach
|
321
|
+
# @return [Boolean] whether or not the attachment was successful
|
322
|
+
def volume_attach?(instance, volume)
|
323
|
+
volume_attached = lambda do |_|
|
324
|
+
volume_attachments.any? do |attachment|
|
325
|
+
attachment['volumeId'] == volume.id
|
326
|
+
end
|
327
|
+
end
|
328
|
+
|
329
|
+
error_log(instance.logger, 'info', "Attaching volume '#{volume.name}' (#{volume.id})...", true)
|
330
|
+
@compute_service.attach_volume(volume.id, instance.id, nil)
|
331
|
+
instance.wait_for(TIMEOUT_VOLUME_ATTACH, &volume_attached)
|
332
|
+
|
333
|
+
error_log(instance.logger, 'info', "Sleeping #{TIMEOUT_VOLUME_PERSIST} seconds for attachment persistance...", true)
|
334
|
+
sleep TIMEOUT_VOLUME_PERSIST
|
335
|
+
|
336
|
+
# In the off chance that the volume host goes down, catch it.
|
337
|
+
if instance.instance_eval(&volume_attached)
|
338
|
+
return true if volume.reload.attachments.first
|
339
|
+
error_log(instance.logger, 'error', "Failed to attach '#{volume.name}': Volume host might be down.", true)
|
340
|
+
else
|
341
|
+
error_log(instance.logger, 'error', "Failed to attach '#{volume.name}': Volume was unexpectedly detached.", true)
|
342
|
+
end
|
343
|
+
|
344
|
+
false
|
345
|
+
rescue Excon::Error => e
|
346
|
+
puts 'Error attaching volume, check log for details.'
|
347
|
+
error_log(instance.logger, 'error', e.message)
|
348
|
+
false
|
349
|
+
rescue Fog::Errors::TimeoutError
|
350
|
+
error_log(instance.logger, 'error', "Failed to attach '#{volume.name}': Operation timed out.", true)
|
351
|
+
false
|
352
|
+
end
|
353
|
+
|
354
|
+
# Test volume mounting and unmounting for an instance and a volume.
|
355
|
+
# @param instance [Fog::Compute::OpenStack::Server] the instance on which to mount the volume
|
356
|
+
# @param username [String] the username to use when logging into the instance
|
357
|
+
# @param volume [Fog::Volume::OpenStack::Volume] the volume to mount
|
358
|
+
# @return [Boolean] whether or not the mounting/unmounting was successful
|
359
|
+
def volume_mount_unmount?(instance, username, volume)
|
360
|
+
mount = INSTANCE_VOLUME_MOUNT_POINT
|
361
|
+
file_name = VOLUME_TEST_FILE_NAME
|
362
|
+
file_contents = VOLUME_TEST_FILE_CONTENTS
|
363
|
+
vdev = @volume_service.volumes.find_by_id(volume.id)
|
364
|
+
.attachments.first['device']
|
365
|
+
vdev << '1'
|
366
|
+
|
367
|
+
log_partitions(instance, username)
|
368
|
+
|
369
|
+
commands = [
|
370
|
+
["echo -e \"127.0.0.1\t$HOSTNAME\" | sudo tee -a /etc/hosts", nil], # to fix problems with sudo and DNS resolution
|
371
|
+
['sudo partprobe -s', nil],
|
372
|
+
["[ -d '#{mount}' ] || sudo mkdir #{mount}", ''],
|
373
|
+
["sudo mount #{vdev} #{mount}", ''],
|
374
|
+
["sudo cat #{mount}/#{file_name}", file_contents],
|
375
|
+
["sudo umount #{mount}", '']
|
376
|
+
]
|
377
|
+
|
378
|
+
error_log(instance.logger, 'info', "Mounting volume '#{volume.name}' (#{volume.id})...", true)
|
379
|
+
|
380
|
+
error_log(instance.logger, 'info', 'Mounting from inside the instance...', true)
|
381
|
+
with_ssh(instance, username) do |ssh|
|
382
|
+
commands.each do |command, expected|
|
383
|
+
result = ssh.exec!(command).chomp
|
384
|
+
if expected.nil?
|
385
|
+
error_log(instance.logger, 'info', "#{command} yielded '#{result}'")
|
386
|
+
elsif result != expected
|
387
|
+
error_log(
|
388
|
+
instance.logger,
|
389
|
+
'error',
|
390
|
+
"Failure while running '#{command}':\n\texpected '#{expected}'\n\tgot '#{result}'",
|
391
|
+
true
|
392
|
+
)
|
393
|
+
return false # returns from volume_mount_unmount?
|
394
|
+
end
|
395
|
+
end
|
396
|
+
end
|
397
|
+
true
|
398
|
+
end
|
399
|
+
|
400
|
+
# Log instance's partition listing.
|
401
|
+
# @param instance [Fog::Compute::OpenStack::Server] the instance to log
|
402
|
+
# @param username [String] the username to use when logging in to the instance
|
403
|
+
def log_partitions(instance, username)
|
404
|
+
puts 'Logging partition list and dmesg...'
|
405
|
+
|
406
|
+
record_info_commands = [
|
407
|
+
'cat /proc/partitions',
|
408
|
+
'dmesg | tail -n 20'
|
409
|
+
]
|
410
|
+
|
411
|
+
with_ssh(instance, username) do |ssh|
|
412
|
+
record_info_commands.each do |command|
|
413
|
+
result = ssh.exec!(command)
|
414
|
+
error_log(instance.logger, 'info', "Ran '#{command}' and got '#{result}'")
|
415
|
+
end
|
416
|
+
end
|
417
|
+
end
|
418
|
+
|
419
|
+
# Detach volume from instance.
|
420
|
+
# @param instance [Fog::Compute::OpenStack::Server] the instance from which to detach
|
421
|
+
# @param volume [Fog::Volume::OpenStack::Volume] the volume to detach
|
422
|
+
# @return [Boolean] whether or not the detachment succeeded
|
423
|
+
def volume_detach?(instance, volume)
|
424
|
+
error_log(instance.logger, 'info', "Detaching #{volume.name}.", true)
|
425
|
+
instance.detach_volume(volume.id)
|
426
|
+
rescue Excon::Error => e
|
427
|
+
puts 'Failed to detach. check log for details.'
|
428
|
+
error_log(instance.logger, 'error', e.message)
|
429
|
+
false
|
430
|
+
end
|
431
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
control 'security-1.0' do
|
3
|
+
impact 1.0
|
4
|
+
title 'Openstack Image Security Test'
|
5
|
+
desc 'Tests the security of images used for Openstack.'
|
6
|
+
|
7
|
+
username = user.username
|
8
|
+
|
9
|
+
describe sshd_config do
|
10
|
+
its('PermitRootLogin') { should eq 'no' }
|
11
|
+
its('PasswordAuthentication') { should eq 'no' }
|
12
|
+
its('ChallengeResponseAuthentication') { should eq 'no' }
|
13
|
+
its('KbdInteractiveAuthentication') { should eq 'no' }
|
14
|
+
end
|
15
|
+
|
16
|
+
describe 'running sshd config' do
|
17
|
+
let(:resource) { command('sudo sshd -T') }
|
18
|
+
|
19
|
+
it 'should not permit root login' do
|
20
|
+
expect(resource.stdout).to cmp(/^PermitRootLogin no/i)
|
21
|
+
end
|
22
|
+
|
23
|
+
it 'should not permit password authentication' do
|
24
|
+
expect(resource.stdout).to cmp(/^PasswordAuthentication no/i)
|
25
|
+
end
|
26
|
+
|
27
|
+
it 'should not permit challenge response authentication' do
|
28
|
+
expect(resource.stdout).to cmp(/^ChallengeResponseAuthentication no/i)
|
29
|
+
end
|
30
|
+
it 'should not permit keyboard interactive authentication' do
|
31
|
+
expect(resource.stdout).to cmp(/^KbdInteractiveAuthentication no/i)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Our version of inspec does not give us a warning about the list matcher,
|
36
|
+
# but in version 2.0 of inspec this will be removed.
|
37
|
+
# This tests the number of instances of sshd on the system.
|
38
|
+
describe processes('sshd') do
|
39
|
+
its('list.length') { should eq 1 }
|
40
|
+
end
|
41
|
+
|
42
|
+
describe.one do
|
43
|
+
describe user(username) do
|
44
|
+
its('groups') { should eq %w(root wheel sudo) }
|
45
|
+
end
|
46
|
+
|
47
|
+
describe command('sudo -U ' + username + ' -l') do
|
48
|
+
its('stdout') { should cmp(/\(ALL\) ((NO)*PASSWD)*: ALL/) }
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
data/tests/inspec.yml
ADDED
metadata
ADDED
@@ -0,0 +1,122 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: openstack_taster
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- OSU Open Source Lab
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2017-07-27 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: inspec
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.10'
|
20
|
+
- - ">="
|
21
|
+
- !ruby/object:Gem::Version
|
22
|
+
version: 1.10.0
|
23
|
+
type: :runtime
|
24
|
+
prerelease: false
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
26
|
+
requirements:
|
27
|
+
- - "~>"
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '1.10'
|
30
|
+
- - ">="
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: 1.10.0
|
33
|
+
- !ruby/object:Gem::Dependency
|
34
|
+
name: fog-openstack
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - "~>"
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: 0.1.19
|
40
|
+
type: :runtime
|
41
|
+
prerelease: false
|
42
|
+
version_requirements: !ruby/object:Gem::Requirement
|
43
|
+
requirements:
|
44
|
+
- - "~>"
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: 0.1.19
|
47
|
+
- !ruby/object:Gem::Dependency
|
48
|
+
name: net-ssh
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
50
|
+
requirements:
|
51
|
+
- - "~>"
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '3.2'
|
54
|
+
- - ">="
|
55
|
+
- !ruby/object:Gem::Version
|
56
|
+
version: 3.2.0
|
57
|
+
type: :runtime
|
58
|
+
prerelease: false
|
59
|
+
version_requirements: !ruby/object:Gem::Requirement
|
60
|
+
requirements:
|
61
|
+
- - "~>"
|
62
|
+
- !ruby/object:Gem::Version
|
63
|
+
version: '3.2'
|
64
|
+
- - ">="
|
65
|
+
- !ruby/object:Gem::Version
|
66
|
+
version: 3.2.0
|
67
|
+
- !ruby/object:Gem::Dependency
|
68
|
+
name: json
|
69
|
+
requirement: !ruby/object:Gem::Requirement
|
70
|
+
requirements:
|
71
|
+
- - "~>"
|
72
|
+
- !ruby/object:Gem::Version
|
73
|
+
version: '1.8'
|
74
|
+
- - ">="
|
75
|
+
- !ruby/object:Gem::Version
|
76
|
+
version: 1.8.6
|
77
|
+
type: :runtime
|
78
|
+
prerelease: false
|
79
|
+
version_requirements: !ruby/object:Gem::Requirement
|
80
|
+
requirements:
|
81
|
+
- - "~>"
|
82
|
+
- !ruby/object:Gem::Version
|
83
|
+
version: '1.8'
|
84
|
+
- - ">="
|
85
|
+
- !ruby/object:Gem::Version
|
86
|
+
version: 1.8.6
|
87
|
+
description: Tastes images on an OpenStack deployment for security and basic usability.
|
88
|
+
email: support@osuosl.org
|
89
|
+
executables:
|
90
|
+
- openstack_taster
|
91
|
+
extensions: []
|
92
|
+
extra_rdoc_files: []
|
93
|
+
files:
|
94
|
+
- bin/openstack_taster
|
95
|
+
- lib/openstack_taster.rb
|
96
|
+
- tests/controls/security_test.rb
|
97
|
+
- tests/inspec.yml
|
98
|
+
homepage: https://github.com/osuosl/openstack_taster
|
99
|
+
licenses:
|
100
|
+
- Apache-2.0
|
101
|
+
metadata: {}
|
102
|
+
post_install_message:
|
103
|
+
rdoc_options: []
|
104
|
+
require_paths:
|
105
|
+
- lib
|
106
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
111
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
112
|
+
requirements:
|
113
|
+
- - ">="
|
114
|
+
- !ruby/object:Gem::Version
|
115
|
+
version: '0'
|
116
|
+
requirements: []
|
117
|
+
rubyforge_project:
|
118
|
+
rubygems_version: 2.6.10
|
119
|
+
signing_key:
|
120
|
+
specification_version: 4
|
121
|
+
summary: Taste all of the OpenStack's basic functionality for an image
|
122
|
+
test_files: []
|