forked from test-kitchen/test-kitchen
-
Notifications
You must be signed in to change notification settings - Fork 0
/
instance.rb
726 lines (656 loc) · 24 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
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
#
# 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" unless defined?(Benchmark)
require "fileutils" unless defined?(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 mutexes, arranged by Plugin 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{[_,/]}, "-").delete(".")
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_accessor :driver
# @return [LifecycleHooks] lifecycle hooks manager object
attr_accessor :lifecycle_hooks
# @return [Provisioner::Base] provisioner object which will the setup
# and invocation instructions for configuration management and other
# automation tools
attr_accessor :provisioner
# @return [Transport::Base] transport object which will communicate with
# an instance.
attr_accessor :transport
# @return [Verifier] verifier object for instance to manage the verifier
# installation on this instance
attr_accessor :verifier
# @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 [Verifier] :verifier the verifier 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)
@lifecycle_hooks = options.fetch(:lifecycle_hooks)
@provisioner = options.fetch(:provisioner)
@transport = options.fetch(:transport)
@verifier = options.fetch(:verifier)
@logger = options.fetch(:logger) { Kitchen.logger }
@state_file = options.fetch(:state_file)
setup_driver
setup_provisioner
setup_transport
setup_verifier
setup_lifecycle_hooks
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 Provisioner::Base#call
# @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 transport. This could be an SSH command, telnet, or serial
# console session.
#
# **Note** This method calls exec and will not return.
#
# @see Kitchen::LoginCommand
# @see Transport::Base::Connection#login_command
def login
state = state_file.read
if state[:last_action].nil?
raise UserError, "Instance #{to_str} has not yet been created"
end
lc = if legacy_ssh_base_driver?
legacy_ssh_base_login(state)
else
transport.connection(state).login_command
end
debug(%{Login command: #{lc.command} #{lc.arguments.join(" ")} } \
"(Options: #{lc.options})")
Kernel.exec(*lc.exec_args)
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
# Perform package.
#
def package_action
banner "Packaging remote instance"
driver.package(state_file.read)
end
# Check system and configuration for common errors.
#
def doctor_action
banner "The doctor is in"
[driver, provisioner, transport, verifier].any? do |obj|
obj.doctor(state_file.read)
end
end
# Returns a Hash of configuration and other useful diagnostic information.
#
# @return [Hash] a diagnostic hash
def diagnose
result = {}
%i{
platform state_file driver provisioner transport verifier lifecycle_hooks
}.each do |sym|
obj = send(sym)
result[sym] = obj.respond_to?(:diagnose) ? obj.diagnose : :unknown
end
result
end
# Returns a Hash of configuration and other useful diagnostic information
# associated with plugins (such as loaded version, class name, etc.).
#
# @return [Hash] a diagnostic hash
def diagnose_plugins
result = {}
%i{driver provisioner verifier transport}.each do |sym|
obj = send(sym)
result[sym] = if obj.respond_to?(:diagnose_plugin)
obj.diagnose_plugin
else
:unknown
end
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
# Returns the error encountered on the last action on the instance
#
# @return [String] the message of the last error
def last_error
state_file.read[:last_error]
end
# Clean up any per-instance resources before exiting.
#
# @return [void]
def cleanup!
@transport.cleanup! if @transport
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)
%i{
suite platform driver provisioner
transport verifier state_file
}.each do |k|
next if options.key?(k)
raise ClientError, "Instance#new requires option :#{k}"
end
end
# If a plugin has declared via .no_parallel_for that it is not
# thread-safe for certain actions, create a mutex to track it.
#
# @param plugin_class[Class] Kitchen::Plugin::Base
# @api private
def setup_plugin_mutexes(plugin_class)
if plugin_class.serial_actions
Kitchen.mutex.synchronize do
self.class.mutexes ||= {}
self.class.mutexes[plugin_class] = Mutex.new
end
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)
setup_plugin_mutexes(driver.class)
end
# Perform any final configuration or preparation needed for the lifecycle hooks
# object carry out its duties.
#
# @api private
def setup_lifecycle_hooks
lifecycle_hooks.finalize_config!(self)
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)
setup_plugin_mutexes(provisioner.class)
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)
setup_plugin_mutexes(transport.class)
end
# Perform any final configuration or preparation needed for the verifier
# object carry out its duties.
#
# @api private
def setup_verifier
verifier.finalize_config!(self)
setup_plugin_mutexes(verifier.class)
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|
@lifecycle_hooks.run_with_hooks(transition, state_file) do
result = send("#{transition}_action")
end
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 Provisioner::Base#call
# @return [self] this instance, used to chain actions
# @api private
def converge_action
banner "Converging #{to_str}..."
elapsed = action(:converge) do |state|
if legacy_ssh_base_driver?
legacy_ssh_base_converge(state)
else
provisioner.check_license
provisioner.call(state)
end
end
info("Finished converging #{to_str} #{Util.duration(elapsed.real)}.")
self
end
# Perform the setup action.
#
# @see Driver::Base#setup
# @return [self] this instance, used to chain actions
# @api private
def setup_action
banner "Setting up #{to_str}..."
elapsed = action(:setup) do |state|
legacy_ssh_base_setup(state) if legacy_ssh_base_driver?
end
info("Finished setting up #{to_str} #{Util.duration(elapsed.real)}.")
self
end
# returns true, if the verifier is busser
def verifier_busser?(verifier)
!defined?(Kitchen::Verifier::Busser).nil? && verifier.is_a?(Kitchen::Verifier::Busser)
end
# returns true, if the verifier is dummy
def verifier_dummy?(verifier)
!defined?(Kitchen::Verifier::Dummy).nil? && verifier.is_a?(Kitchen::Verifier::Dummy)
end
def use_legacy_ssh_verifier?(verifier)
verifier_busser?(verifier) || verifier_dummy?(verifier)
end
# Perform the verify action.
#
# @see Driver::Base#verify
# @return [self] this instance, used to chain actions
# @api private
def verify_action
banner "Verifying #{to_str}..."
elapsed = action(:verify) do |state|
# use special handling for legacy driver
if legacy_ssh_base_driver? && use_legacy_ssh_verifier?(verifier)
legacy_ssh_base_verify(state)
elsif legacy_ssh_base_driver?
# read ssh options from legacy driver
verifier.call(driver.legacy_state(state))
else
verifier.call(state)
end
end
info("Finished verifying #{to_str} #{Util.duration(elapsed.real)}.")
self
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
state[:last_error] = nil
elapsed
rescue ActionFailed => e
log_failure(what, e)
state[:last_error] = e.class.name
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)
state[:last_error] = e.class.name
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)
plugin_class = plugin_class_for_action(what)
if Array(plugin_class.serial_actions).include?(what)
debug("#{to_str} is synchronizing on #{plugin_class}##{what}")
self.class.mutexes[plugin_class].synchronize do
debug("#{to_str} is messaging #{plugin_class}##{what}")
yield(state)
end
else
yield(state)
end
end
# Maps the given action to the plugin class associated with the action
#
# @param what[Symbol] action
# @return [Class] Kitchen::Plugin::Base
# @api private
def plugin_class_for_action(what)
{
create: driver,
setup: transport,
converge: provisioner,
verify: verifier,
destroy: driver,
}[what].class
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
# Invokes `Driver#converge` on a legacy Driver, which inherits from
# `Kitchen::Driver::SSHBase`.
#
# @param state [Hash] mutable instance state
# @deprecated When legacy Driver::SSHBase support is removed, the
# `#converge` method will no longer be called on the Driver.
# @api private
def legacy_ssh_base_converge(state)
warn("Running legacy converge for '#{driver.name}' Driver")
# TODO: Document upgrade path and provide link
# warn("Driver authors: please read http://example.com for more details.")
driver.converge(state)
end
# @return [TrueClass,FalseClass] whether or not the Driver inherits from
# `Kitchen::Driver::SSHBase`
# @deprecated When legacy Driver::SSHBase support is removed, the
# `#converge` method will no longer be called on the Driver.
# @api private
def legacy_ssh_base_driver?
driver.class < Kitchen::Driver::SSHBase
end
# Invokes `Driver#login_command` on a legacy Driver, which inherits from
# `Kitchen::Driver::SSHBase`.
#
# @param state [Hash] mutable instance state
# @deprecated When legacy Driver::SSHBase support is removed, the
# `#login_command` method will no longer be called on the Driver.
# @api private
def legacy_ssh_base_login(state)
warn("Running legacy login for '#{driver.name}' Driver")
# TODO: Document upgrade path and provide link
# warn("Driver authors: please read http://example.com for more details.")
driver.login_command(state)
end
# Invokes `Driver#setup` on a legacy Driver, which inherits from
# `Kitchen::Driver::SSHBase`.
#
# @param state [Hash] mutable instance state
# @deprecated When legacy Driver::SSHBase support is removed, the
# `#setup` method will no longer be called on the Driver.
# @api private
def legacy_ssh_base_setup(state)
warn("Running legacy setup for '#{driver.name}' Driver")
# TODO: Document upgrade path and provide link
# warn("Driver authors: please read http://example.com for more details.")
driver.setup(state)
end
# Invokes `Driver#verify` on a legacy Driver, which inherits from
# `Kitchen::Driver::SSHBase`.
#
# @param state [Hash] mutable instance state
# @deprecated When legacy Driver::SSHBase support is removed, the
# `#verify` method will no longer be called on the Driver.
# @api private
def legacy_ssh_base_verify(state)
warn("Running legacy verify for '#{driver.name}' Driver")
# TODO: Document upgrade path and provide link
# warn("Driver authors: please read http://example.com for more details.")
driver.verify(state)
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 = %i{destroy create converge setup verify}.freeze
# 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