forked from test-kitchen/test-kitchen
-
Notifications
You must be signed in to change notification settings - Fork 0
/
instance.rb
526 lines (468 loc) · 17.1 KB
/
instance.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
# -*- encoding: utf-8 -*-
#
# Author:: Fletcher Nichol (<[email protected]>)
#
# Copyright (C) 2012, 2013, 2014, Fletcher Nichol
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
require "benchmark"
require "fileutils"
module Kitchen
# An instance of a suite running on a platform. A created instance may be a
# local virtual machine, cloud instance, container, or even a bare metal
# server, which is determined by the platform's driver.
#
# @author Fletcher Nichol <[email protected]>
class Instance
include Logging
class << self
# @return [Hash] a hash of mutxes, arranged by Driver class names
# @api private
attr_accessor :mutexes
# Generates a name for an instance given a suite and platform.
#
# @param suite [Suite,#name] a Suite
# @param platform [Platform,#name] a Platform
# @return [String] a normalized, consistent name for an instance
def name_for(suite, platform)
"#{suite.name}-#{platform.name}".gsub(%r{[_,/]}, "-").gsub(/\./, "")
end
end
# @return [Suite] the test suite configuration
attr_reader :suite
# @return [Platform] the target platform configuration
attr_reader :platform
# @return [String] name of this instance
attr_reader :name
# @return [Driver::Base] driver object which will manage this instance's
# lifecycle actions
attr_reader :driver
# @return [Provisioner::Base] provisioner object which will the setup
# and invocation instructions for configuration management and other
# automation tools
attr_reader :provisioner
# @return [Transport::Base] transport object which will communicate with
# an instance.
attr_reader :transport
# @return [Busser] busser object for instance to manage the busser
# installation on this instance
attr_reader :busser
# @return [Logger] the logger for this instance
attr_reader :logger
# Creates a new instance, given a suite and a platform.
#
# @param [Hash] options configuration for a new suite
# @option options [Suite] :suite the suite (**Required**)
# @option options [Platform] :platform the platform (**Required**)
# @option options [Driver::Base] :driver the driver (**Required**)
# @option options [Provisioner::Base] :provisioner the provisioner
# @option options [Transport::Base] :transport the transport
# (**Required**)
# @option options [Busser] :busser the busser logger (**Required**)
# @option options [Logger] :logger the instance logger
# (default: Kitchen.logger)
# @option options [StateFile] :state_file the state file object to use
# when tracking instance state (**Required**)
# @raise [ClientError] if one or more required options are omitted
def initialize(options = {})
validate_options(options)
@suite = options.fetch(:suite)
@platform = options.fetch(:platform)
@name = self.class.name_for(@suite, @platform)
@driver = options.fetch(:driver)
@provisioner = options.fetch(:provisioner)
@transport = options.fetch(:transport)
@busser = options.fetch(:busser)
@logger = options.fetch(:logger) { Kitchen.logger }
@state_file = options.fetch(:state_file)
setup_driver
setup_provisioner
setup_transport
setup_busser
end
# Returns a displayable representation of the instance.
#
# @return [String] an instance display string
def to_str
"<#{name}>"
end
# Creates this instance.
#
# @see Driver::Base#create
# @return [self] this instance, used to chain actions
#
# @todo rescue Driver::ActionFailed and return some kind of null object
# to gracfully stop action chaining
def create
transition_to(:create)
end
# Converges this running instance.
#
# @see Driver::Base#converge
# @return [self] this instance, used to chain actions
#
# @todo rescue Driver::ActionFailed and return some kind of null object
# to gracfully stop action chaining
def converge
transition_to(:converge)
end
# Sets up this converged instance for suite tests.
#
# @see Driver::Base#setup
# @return [self] this instance, used to chain actions
#
# @todo rescue Driver::ActionFailed and return some kind of null object
# to gracfully stop action chaining
def setup
transition_to(:setup)
end
# Verifies this set up instance by executing suite tests.
#
# @see Driver::Base#verify
# @return [self] this instance, used to chain actions
#
# @todo rescue Driver::ActionFailed and return some kind of null object
# to gracfully stop action chaining
def verify
transition_to(:verify)
end
# Destroys this instance.
#
# @see Driver::Base#destroy
# @return [self] this instance, used to chain actions
#
# @todo rescue Driver::ActionFailed and return some kind of null object
# to gracfully stop action chaining
def destroy
transition_to(:destroy)
end
# Tests this instance by creating, converging and verifying. If this
# instance is running, it will be pre-emptively destroyed to ensure a
# clean slate. The instance will be left post-verify in a running state.
#
# @param destroy_mode [Symbol] strategy used to cleanup after instance
# has finished verifying (default: `:passing`)
# @return [self] this instance, used to chain actions
#
# @todo rescue Driver::ActionFailed and return some kind of null object
# to gracfully stop action chaining
def test(destroy_mode = :passing)
elapsed = Benchmark.measure do
banner "Cleaning up any prior instances of #{to_str}"
destroy
banner "Testing #{to_str}"
verify
destroy if destroy_mode == :passing
end
info "Finished testing #{to_str} #{Util.duration(elapsed.real)}."
self
ensure
destroy if destroy_mode == :always
end
# Logs in to this instance by invoking a system command, provided by the
# instance's driver. This could be an SSH command, telnet, or serial
# console session.
#
# **Note** This method calls exec and will not return.
#
# @see Driver::LoginCommand
# @see Driver::Base#login_command
def login
state = state_file.read
if state[:last_action].nil?
raise UserError, "Instance #{to_str} has not yet been created"
end
login_command = driver.login_command(state)
cmd, *args = login_command.cmd_array
options = login_command.options
debug(%{Login command: #{cmd} #{args.join(" ")} (Options: #{options})})
Kernel.exec(cmd, *args, options)
end
# Executes an arbitrary command on this instance.
#
# @param command [String] a command string to execute
def remote_exec(command)
@transport.connection(state_file.read) do |conn|
conn.execute(command)
end
end
# Returns a Hash of configuration and other useful diagnostic information.
#
# @return [Hash] a diagnostic hash
def diagnose
result = Hash.new
[:state_file, :driver, :provisioner, :transport, :busser].each do |sym|
obj = send(sym)
result[sym] = obj.respond_to?(:diagnose) ? obj.diagnose : :unknown
end
result
end
# Returns the last successfully completed action state of the instance.
#
# @return [String] a named action which was last successfully completed
def last_action
state_file.read[:last_action]
end
private
# @return [StateFile] a state file object that can be read from or written
# to
# @api private
attr_reader :state_file
# Validate the initial internal state of this object and raising an
# exception if any preconditions are not met.
#
# @param options[Hash] options hash passed into the constructor
# @raise [ClientError] if any validations fail
# @api private
def validate_options(options)
[
:suite, :platform, :driver, :provisioner, :transport, :busser, :state_file
].each do |k|
next if options.key?(k)
raise ClientError, "Instance#new requires option :#{k}"
end
end
# Perform any final configuration or preparation needed for the driver
# object carry out its duties.
#
# @api private
def setup_driver
@driver.finalize_config!(self)
if driver.class.serial_actions
Kitchen.mutex.synchronize do
self.class.mutexes ||= Hash.new
self.class.mutexes[driver.class] = Mutex.new
end
end
end
# Perform any final configuration or preparation needed for the provisioner
# object carry out its duties.
#
# @api private
def setup_provisioner
@provisioner.finalize_config!(self)
end
# Perform any final configuration or preparation needed for the transport
# object carry out its duties.
#
# @api private
def setup_transport
@transport.finalize_config!(self)
end
# Perform any final configuration or preparation needed for busser to
# carry out its duties.
#
# @api private
def setup_busser
@busser.finalize_config!(self)
end
# Perform all actions in order from last state to desired state.
#
# @param desired [Symbol] a symbol representing the desired action state
# @return [self] this instance, used to chain actions
# @api private
def transition_to(desired)
result = nil
FSM.actions(last_action, desired).each do |transition|
result = send("#{transition}_action")
end
result
end
# Perform the create action.
#
# @see Driver::Base#create
# @return [self] this instance, used to chain actions
# @api private
def create_action
perform_action(:create, "Creating")
end
# Perform the converge action.
#
# @see Driver::Base#converge
# @return [self] this instance, used to chain actions
# @api private
def converge_action
perform_action(:converge, "Converging")
end
# Perform the setup action.
#
# @see Driver::Base#setup
# @return [self] this instance, used to chain actions
# @api private
def setup_action
perform_action(:setup, "Setting up")
end
# Perform the verify action.
#
# @see Driver::Base#verify
# @return [self] this instance, used to chain actions
# @api private
def verify_action
perform_action(:verify, "Verifying")
end
# Perform the destroy action.
#
# @see Driver::Base#destroy
# @return [self] this instance, used to chain actions
# @api private
def destroy_action
perform_action(:destroy, "Destroying") { state_file.destroy }
end
# Perform an arbitrary action and provide useful logging.
#
# @param verb [Symbol] the action to be performed
# @param output_verb [String] a verb representing the action, suitable for
# use in output logging
# @yield perform optional work just after action has complted
# @return [self] this instance, used to chain actions
# @api private
def perform_action(verb, output_verb)
banner "#{output_verb} #{to_str}..."
elapsed = action(verb) { |state| driver.public_send(verb, state) }
info("Finished #{output_verb.downcase} #{to_str}" \
" #{Util.duration(elapsed.real)}.")
yield if block_given?
self
end
# Times a call to an action block and handles any raised exceptions. This
# method ensures that the last successfully completed action is persisted
# to the state file. The last action state will either be the desired
# action that is passed in or the previous action that was persisted to the
# state file.
#
# @param what [Symbol] the action to be performed
# @param block [Proc] a block to be called
# @return [Benchmark::Tms] timing information for the given action
# @raise [InstanceFailed] if a driver action fails to complete, signaled
# by a driver raising an ActionFailed exception. Typical reasons for this
# would be a driver create action failing, a chef convergence crashing
# in normal course of development, failing acceptance tests in the
# verify action, etc.
# @raise [ActionFailed] if an unforseen or unplanned exception is raised.
# This would usually indicate that a race condition was triggered, a
# bug exists in a driver, provisioner, or core, a transient IO error
# occured, etc.
# @api private
def action(what, &block)
state = state_file.read
elapsed = Benchmark.measure do
synchronize_or_call(what, state, &block)
end
state[:last_action] = what.to_s
elapsed
rescue ActionFailed => e
log_failure(what, e)
raise(InstanceFailure, failure_message(what) +
" Please see .kitchen/logs/#{name}.log for more details",
e.backtrace)
rescue Exception => e # rubocop:disable Lint/RescueException
log_failure(what, e)
raise ActionFailed,
"Failed to complete ##{what} action: [#{e.message}]", e.backtrace
ensure
state_file.write(state)
end
# Runs a given action block through a common driver mutex if required or
# runs it directly otherwise. If a driver class' `.serial_actions` array
# includes the desired action, then the action must be run with a muxtex
# lock. Otherwise, it is assumed that the action can happen concurrently,
# or fully in parallel.
#
# @param what [Symbol] the action to be performed
# @param state [Hash] a mutable state hash for this instance
# @param block [Proc] a block to be called
# @api private
def synchronize_or_call(what, state, &block)
if Array(driver.class.serial_actions).include?(what)
debug("#{to_str} is synchronizing on #{driver.class}##{what}")
self.class.mutexes[driver.class].synchronize do
debug("#{to_str} is messaging #{driver.class}##{what}")
block.call(state)
end
else
block.call(state)
end
end
# Writes a high level message for logging and/or output.
#
# In this case, all instance banner messages will be written to the common
# Kitchen logger so that the high level flow of a run can be followed in
# the kitchen.log file.
#
# @api private
def banner(*args)
Kitchen.logger.logdev && Kitchen.logger.logdev.banner(*args)
super
end
# Logs a failure (message and backtrace) to the instance's file logger
# to help with debugging and diagnosing issues without overwhelming the
# console output in the default case (i.e. running kitchen with :info
# level debugging).
#
# @param what [String] an action
# @param e [Exception] an exception
# @api private
def log_failure(what, e)
return if logger.logdev.nil?
logger.logdev.error(failure_message(what))
Error.formatted_trace(e).each { |line| logger.logdev.error(line) }
end
# Returns a string explaining what action failed, at a high level. Used
# for displaying to end user.
#
# @param what [String] an action
# @return [String] a failure message
# @api private
def failure_message(what)
"#{what.capitalize} failed on instance #{to_str}."
end
# The simplest finite state machine pseudo-implementation needed to manage
# an Instance.
#
# @api private
# @author Fletcher Nichol <[email protected]>
class FSM
# Returns an Array of all transitions to bring an Instance from its last
# reported transistioned state into the desired transitioned state.
#
# @param last [String,Symbol,nil] the last known transitioned state of
# the Instance, defaulting to `nil` (for unknown or no history)
# @param desired [String,Symbol] the desired transitioned state for the
# Instance
# @return [Array<Symbol>] an Array of transition actions to perform
# @api private
def self.actions(last = nil, desired)
last_index = index(last)
desired_index = index(desired)
if last_index == desired_index || last_index > desired_index
Array(TRANSITIONS[desired_index])
else
TRANSITIONS.slice(last_index + 1, desired_index - last_index)
end
end
TRANSITIONS = [:destroy, :create, :converge, :setup, :verify]
# Determines the index of a state in the state lifecycle vector. Woah.
#
# @param transition [Symbol,#to_sym] a state
# @param [Integer] the index position
# @api private
def self.index(transition)
if transition.nil?
0
else
TRANSITIONS.find_index { |t| t == transition.to_sym }
end
end
end
end
end