kitchen-ec2 3.22.3 → 3.22.4

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.
@@ -49,6 +49,7 @@ require "socket" unless defined?(Socket)
49
49
  require "shellwords" unless defined?(Shellwords)
50
50
 
51
51
  module Kitchen
52
+ # Namespace for Test Kitchen driver plugins.
52
53
  module Driver
53
54
  # Amazon EC2 driver for Test Kitchen.
54
55
  #
@@ -107,15 +108,33 @@ module Kitchen
107
108
 
108
109
  include Kitchen::Driver::Mixins::DedicatedHosts
109
110
 
111
+ # @param args [Array] passed through to {Kitchen::Driver::Base}
112
+ # @param block [Proc] passed through to {Kitchen::Driver::Base}
110
113
  def initialize(*args, &block)
111
114
  super
112
115
  end
113
116
 
117
+ # Warn that a config key is deprecated but still honored.
118
+ #
119
+ # @param driver [Kitchen::Driver::Ec2] the driver being validated
120
+ # @param old_key [Symbol] the deprecated key
121
+ # @param new_key [String, Symbol] what to use instead
122
+ # @return [void]
114
123
  def self.validation_warn(driver, old_key, new_key)
115
124
  driver.warn "WARN: The driver[#{driver.class.name}] config key `#{old_key}` " \
116
125
  "is deprecated, please use `#{new_key}`"
117
126
  end
118
127
 
128
+ # Report that a config key has been removed, and stop.
129
+ #
130
+ # Continuing would silently ignore a setting the user believes is in
131
+ # effect, which for keys like `ebs_volume_size` changes the shape of the
132
+ # instance that gets built.
133
+ #
134
+ # @param driver [Kitchen::Driver::Ec2] the driver being validated
135
+ # @param old_key [Symbol] the removed key
136
+ # @param new_key [String, Symbol] what to use instead
137
+ # @return [void] never returns; terminates the process with `exit!`
119
138
  def self.validation_error(driver, old_key, new_key)
120
139
  warn "ERROR: The driver[#{driver.class.name}] config key `#{old_key}` " \
121
140
  "has been removed, please use `#{new_key}`"
@@ -220,6 +239,20 @@ module Kitchen
220
239
  end
221
240
  end
222
241
 
242
+ # Create an EC2 instance and wait until it can be connected to.
243
+ #
244
+ # Auto-creates a security group and key pair when none were configured,
245
+ # allocates a dedicated host if `tenancy: host` requires one, requests
246
+ # either an on-demand or a spot instance, then waits for the instance to
247
+ # exist, become ready, and accept a transport connection.
248
+ #
249
+ # Any failure destroys the instance and everything auto-created alongside
250
+ # it, so that a failed create does not leave billable resources behind.
251
+ #
252
+ # @param state [Hash] the instance state, updated in place with
253
+ # `:server_id`, `:hostname` and any auto-created credentials
254
+ # @return [void]
255
+ # @raise [RuntimeError] wrapping whatever went wrong, after cleaning up
223
256
  def create(state)
224
257
  return if state[:server_id]
225
258
 
@@ -308,6 +341,15 @@ module Kitchen
308
341
  raise "#{e.message} in the specified region #{config[:region]}. Please check this AMI is available in this region."
309
342
  end
310
343
 
344
+ # Terminate the instance and clean up everything created alongside it.
345
+ #
346
+ # An instance that no longer exists is treated as success, since `kitchen
347
+ # destroy` is also how a failed create is cleaned up. Termination is
348
+ # waited on only when an auto-created security group needs removing, as
349
+ # the group cannot be deleted while an instance still references it.
350
+ #
351
+ # @param state [Hash] the instance state, cleaned up in place
352
+ # @return [void]
311
353
  def destroy(state)
312
354
  if state[:server_id]
313
355
  server = ec2.get_instance(state[:server_id])
@@ -350,6 +392,12 @@ module Kitchen
350
392
  empty_hosts.each { |host| deallocate_host(host.host_id) }
351
393
  end
352
394
 
395
+ # The EC2 image this instance will be created from.
396
+ #
397
+ # @return [Aws::EC2::Image]
398
+ # @raise [RuntimeError] when neither `image_id` nor `image_search` yielded
399
+ # an image, which happens when the platform name is not recognized and
400
+ # no explicit search was configured
353
401
  def image
354
402
  return @image if defined?(@image)
355
403
 
@@ -365,6 +413,12 @@ module Kitchen
365
413
  @image
366
414
  end
367
415
 
416
+ # The instance type to use when the user did not choose one.
417
+ #
418
+ # t2 instances require a hardware-virtualized image, so a paravirtual
419
+ # image falls back to the older t1 family.
420
+ #
421
+ # @return [String] a free-tier instance type
368
422
  def default_instance_type
369
423
  @instance_type ||= if image && image.virtualization_type == "hvm"
370
424
  info("instance_type not specified. Using free tier t2.micro instance ...")
@@ -376,11 +430,22 @@ module Kitchen
376
430
  end
377
431
  end
378
432
 
379
- # The actual platform is the platform detected from the image
433
+ # The platform detected from the image actually being used.
434
+ #
435
+ # This can differ from {#desired_platform}: the user asks for "ubuntu" and
436
+ # gets whichever Ubuntu release the search matched. It is the source of
437
+ # the default SSH username.
438
+ #
439
+ # @return [Kitchen::Driver::Aws::StandardPlatform, nil] nil when no
440
+ # platform recognizes the image
380
441
  def actual_platform
381
442
  @actual_platform ||= Aws::StandardPlatform.from_image(self, image) if image
382
443
  end
383
444
 
445
+ # The platform requested by the Test Kitchen platform name.
446
+ #
447
+ # @return [Kitchen::Driver::Aws::StandardPlatform, nil] nil when the
448
+ # platform name is not one the driver knows how to search for
384
449
  def desired_platform
385
450
  @desired_platform ||= begin
386
451
  platform = Aws::StandardPlatform.from_platform_string(self, instance.platform.name)
@@ -392,6 +457,12 @@ module Kitchen
392
457
  end
393
458
  end
394
459
 
460
+ # Search for an image matching the requested platform.
461
+ #
462
+ # Falls back to searching for Ubuntu when the platform name is not
463
+ # recognized, so that `kitchen create` still does something useful.
464
+ #
465
+ # @return [String, nil] the image ID, or nil when the search matched nothing
395
466
  def default_ami
396
467
  @default_ami ||= begin
397
468
  search_platform = desired_platform ||
@@ -401,6 +472,13 @@ module Kitchen
401
472
  end
402
473
  end
403
474
 
475
+ # Record the platform's default SSH username in the instance state.
476
+ #
477
+ # Only applied when the transport is still using its own default username,
478
+ # so that a username the user configured is never overwritten.
479
+ #
480
+ # @param state [Hash] the instance state, updated in place
481
+ # @return [void]
404
482
  def update_username(state)
405
483
  # BUG: With the following equality condition on username, if the user specifies 'root'
406
484
  # as the transport's username then we will overwrite that value with one from the standard
@@ -416,6 +494,9 @@ module Kitchen
416
494
  end
417
495
  end
418
496
 
497
+ # The EC2 client wrapper, configured from the driver config.
498
+ #
499
+ # @return [Kitchen::Driver::Aws::Client]
419
500
  def ec2
420
501
  @ec2 ||= Aws::Client.new(
421
502
  config[:region],
@@ -426,11 +507,20 @@ module Kitchen
426
507
  )
427
508
  end
428
509
 
510
+ # A generator for the RunInstances payload.
511
+ #
512
+ # @note Deliberately reassigned rather than memoized with `||=`: spot
513
+ # requests retry against a rewritten {#config}, and a cached generator
514
+ # would keep building the payload from the config of the first attempt.
515
+ #
516
+ # @return [Kitchen::Driver::Aws::InstanceGenerator]
429
517
  def instance_generator
430
518
  @instance_generator = Aws::InstanceGenerator.new(config, ec2, instance.logger)
431
519
  end
432
520
 
433
- # AWS helper for creating the instance
521
+ # Request a single on-demand instance.
522
+ #
523
+ # @return [Aws::EC2::Instance] the newly requested instance
434
524
  def submit_server
435
525
  instance_data = instance_generator.ec2_instance_data
436
526
  debug("Creating EC2 instance in region #{config[:region]} with properties:")
@@ -441,13 +531,29 @@ module Kitchen
441
531
  ec2.create_instance(instance_data)
442
532
  end
443
533
 
534
+ # The driver config.
535
+ #
536
+ # {#submit_spots} overrides this with a rewritten config while trying each
537
+ # instance type and subnet combination, so the generator and the rest of
538
+ # the driver see the variant currently being attempted.
539
+ #
540
+ # @return [Hash] the config in effect
444
541
  def config
445
542
  return super unless @config
446
543
 
447
544
  @config
448
545
  end
449
546
 
450
- # Take one config and expand to multiple configs
547
+ # Expand a config whose value for `key` is a list into one config per
548
+ # element.
549
+ #
550
+ # Used to turn `instance_type: [a, b]` into two candidate configs to try
551
+ # in turn. The original config is cloned rather than mutated.
552
+ #
553
+ # @param conf [Hash] the config to expand
554
+ # @param key [Symbol] the key that may hold a list
555
+ # @return [Array<Hash>] one config per value, or `[conf]` when the value
556
+ # is not a list
451
557
  def expand_config(conf, key)
452
558
  configs = []
453
559
 
@@ -465,6 +571,15 @@ module Kitchen
465
571
  configs
466
572
  end
467
573
 
574
+ # Request a spot instance, trying each viable configuration in turn.
575
+ #
576
+ # Spot capacity is per instance type and per availability zone, so a
577
+ # request can fail for reasons that a different type or subnet would
578
+ # satisfy. Every combination of instance type and subnet is attempted
579
+ # before giving up, and all the failures are reported together.
580
+ #
581
+ # @return [Aws::EC2::Instance] the first instance that could be fulfilled
582
+ # @raise [RuntimeError] listing every failure when none could be fulfilled
468
583
  def submit_spots
469
584
  configs = [config]
470
585
  expanded = []
@@ -518,6 +633,18 @@ module Kitchen
518
633
  raise ["Could not create a spot instance:", errs].flatten.join("\n")
519
634
  end
520
635
 
636
+ # Request a single spot instance for the current config.
637
+ #
638
+ # A `spot_price` of "ondemand" or "on-demand" requests a spot instance
639
+ # with no price cap, which EC2 expresses by omitting `max_price`.
640
+ #
641
+ # `create_instances` is used rather than `request_spot_instances` because
642
+ # only the former can tag an instance at creation time; the retry loop
643
+ # compensates for its lack of built-in waiting.
644
+ #
645
+ # @return [Aws::EC2::Instance] the newly requested instance
646
+ # @raise [Aws::EC2::Errors::SpotMaxPriceTooLow] when the price could not be
647
+ # satisfied within `spot_wait` seconds
521
648
  def submit_spot
522
649
  debug("Creating EC2 Spot Instance..")
523
650
  instance_data = instance_generator.ec2_instance_data
@@ -562,8 +689,16 @@ module Kitchen
562
689
  end
563
690
  end
564
691
 
565
- # Normally we could use `server.wait_until_running` but we actually need
566
- # to check more than just the instance state
692
+ # Wait until an instance is genuinely usable.
693
+ #
694
+ # `server.wait_until_running` is not sufficient: an instance can report
695
+ # running before it has an address, and a Windows instance is not usable
696
+ # until its console output says so. The hostname is stored as soon as it
697
+ # is known so that a later failure still leaves enough state to clean up.
698
+ #
699
+ # @param server [Aws::EC2::Instance] the instance to wait on
700
+ # @param state [Hash] the instance state, updated in place
701
+ # @return [void]
567
702
  def wait_until_ready(server, state)
568
703
  wait_with_destroy(server, state, "to become ready") do |aws_instance|
569
704
  hostname = hostname(aws_instance, config[:interface])
@@ -598,8 +733,18 @@ module Kitchen
598
733
  end
599
734
  end
600
735
 
601
- # Poll a block, waiting for it to return true. If it does not succeed
602
- # within the configured time we destroy the instance to save people money
736
+ # Poll until a block returns true, destroying the instance if it never does.
737
+ #
738
+ # An instance that never becomes ready would otherwise keep running and
739
+ # accruing charges after Test Kitchen gave up on it.
740
+ #
741
+ # @param server [Aws::EC2::Instance] the instance to wait on
742
+ # @param state [Hash] the instance state
743
+ # @param status_msg [String] what is being waited for, for log messages
744
+ # @yieldparam aws_instance [Aws::EC2::Instance] the instance being polled
745
+ # @yieldreturn [Boolean] true when the wait is over
746
+ # @return [void]
747
+ # @raise [Aws::Waiters::Errors::WaiterFailed] after destroying the instance
603
748
  def wait_with_destroy(server, state, status_msg, &block)
604
749
  wait_log = proc do |attempts|
605
750
  c = attempts * config[:retryable_sleep]
@@ -623,6 +768,14 @@ module Kitchen
623
768
  end
624
769
  end
625
770
 
771
+ # Wait for and decrypt the generated Windows administrator password.
772
+ #
773
+ # EC2 returns blank password data until the password is available, so this
774
+ # polls first and then decrypts with the instance's private key.
775
+ #
776
+ # @param server [Aws::EC2::Instance] the instance
777
+ # @param state [Hash] the instance state, updated in place with `:password`
778
+ # @return [void]
626
779
  def fetch_windows_admin_password(server, state)
627
780
  wait_with_destroy(server, state, "to fetch windows admin password") do |_aws_instance|
628
781
  enc = server.client.get_password_data(
@@ -638,6 +791,14 @@ module Kitchen
638
791
  info("Retrieved Windows password for instance <#{state[:server_id]}>.")
639
792
  end
640
793
 
794
+ # Retry a block with quadratic backoff when EC2 throttles the request.
795
+ #
796
+ # Only throttling is retried; any other error is re-raised immediately so
797
+ # that a genuine failure is not delayed by five pointless retries.
798
+ #
799
+ # @param state [Hash] the instance state, used for log messages
800
+ # @yieldreturn [Object] the block's value
801
+ # @return [Object] the block's value
641
802
  def with_request_limit_backoff(state)
642
803
  retries = 0
643
804
  begin
@@ -653,10 +814,11 @@ module Kitchen
653
814
  end
654
815
  end
655
816
 
817
+ # Mapping from the `interface` config value to the EC2 instance attribute
818
+ # holding that address, in the order they are preferred when no interface
819
+ # was requested.
656
820
  #
657
- # Ordered mapping from config name to Fog name. Ordered by preference
658
- # when looking up hostname.
659
- #
821
+ # @return [Hash{String => String}]
660
822
  INTERFACE_TYPES =
661
823
  {
662
824
  "dns" => "public_dns_name",
@@ -671,6 +833,16 @@ module Kitchen
671
833
  # that interface to lookup hostname. Otherwise, try ordered list of
672
834
  # options.
673
835
  #
836
+ # The address to connect to an instance on.
837
+ #
838
+ # With no interface type, {INTERFACE_TYPES} is walked in order and the
839
+ # first populated value wins. AWS returns an empty string rather than nil
840
+ # for an address that is not assigned yet, so empty values are skipped.
841
+ #
842
+ # @param server [Aws::EC2::Instance] the instance
843
+ # @param interface_type [String, nil] one of the keys of {INTERFACE_TYPES}
844
+ # @return [String, nil] the address, or nil when none is available yet
845
+ # @raise [Kitchen::UserError] when `interface_type` is not recognized
674
846
  def hostname(server, interface_type = nil)
675
847
  if interface_type
676
848
  interface_type = INTERFACE_TYPES.fetch(interface_type) do
@@ -691,10 +863,20 @@ module Kitchen
691
863
  #
692
864
  # Returns the sudo command to use or empty string if sudo is not configured
693
865
  #
866
+ # The command used to elevate privileges, if any.
867
+ #
868
+ # @return [String] the sudo command, or an empty string when sudo is off
694
869
  def sudo_command
695
870
  instance.provisioner[:sudo] ? instance.provisioner[:sudo_command].to_s : ""
696
871
  end
697
872
 
873
+ # Write the Ohai EC2 hint file on the instance.
874
+ #
875
+ # Chef's `ec2` Ohai plugin only collects EC2 metadata when this hint file
876
+ # is present, so it is created for Chef provisioners.
877
+ #
878
+ # @param state [Hash] the instance state
879
+ # @return [void]
698
880
  def create_ec2_json(state)
699
881
  if windows_os?
700
882
  cmd = 'New-Item -Force C:\\chef\\ohai\\hints\\ec2.json -ItemType File'
@@ -705,6 +887,18 @@ module Kitchen
705
887
  instance.transport.connection(state).execute(cmd)
706
888
  end
707
889
 
890
+ # The default PowerShell user data script for Windows instances.
891
+ #
892
+ # Enables PS remoting, opens the WinRM firewall port and configures WinRM
893
+ # limits, without which a freshly created Windows instance cannot be
894
+ # connected to. Handles both EC2Launch (2016+) and the older EC2Config
895
+ # service, which log to different paths.
896
+ #
897
+ # When the transport uses an account other than Administrator, a matching
898
+ # local account is created and password complexity is relaxed first, since
899
+ # a generated password may not satisfy the default policy.
900
+ #
901
+ # @return [String] a PowerShell script wrapped in `<powershell>` tags
708
902
  def default_windows_user_data
709
903
  base_script = Kitchen::Util.outdent!(<<-EOH)
710
904
  $OSVersion = (get-itemproperty -Path "HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion" -Name ProductName).ProductName
@@ -768,6 +962,9 @@ module Kitchen
768
962
  EOH
769
963
  end
770
964
 
965
+ # Log which image was chosen and what platform was detected on it.
966
+ #
967
+ # @return [void]
771
968
  def show_chosen_image
772
969
  # Print some debug stuff
773
970
  debug("Image for #{instance.name}: #{image.name}. #{image_info(image)}")
@@ -780,6 +977,10 @@ module Kitchen
780
977
  end
781
978
  end
782
979
 
980
+ # A one-line summary of the attributes that drive image selection.
981
+ #
982
+ # @param image [Aws::EC2::Image] the image to describe
983
+ # @return [String] architecture, virtualization, storage and creation date
783
984
  def image_info(image)
784
985
  root_device = image.block_device_mappings
785
986
  .find { |b| b.device_name == image.root_device_name }
@@ -920,6 +1121,15 @@ module Kitchen
920
1121
  state[:ssh_key] = key_path
921
1122
  end
922
1123
 
1124
+ # Attach a pre-existing elastic network interface to the instance.
1125
+ #
1126
+ # Attached at device index 1, leaving index 0 for the primary interface.
1127
+ # An interface that is already attached is left alone, and one that does
1128
+ # not exist is reported without failing the run, since the instance itself
1129
+ # is already up by this point.
1130
+ #
1131
+ # @param state [Hash] the instance state
1132
+ # @return [void]
923
1133
  def attach_network_interface(state)
924
1134
  info("Attaching Network interface <#{config[:elastic_network_interface_id]}> with the instance <#{state[:server_id]}> .")
925
1135
  client = ::Aws::EC2::Client.new(region: config[:region])
@@ -972,6 +1182,14 @@ module Kitchen
972
1182
  File.unlink("#{config[:kitchen_root]}/.kitchen/#{instance.name}.pem")
973
1183
  end
974
1184
 
1185
+ # Finalize the driver config and install transport overrides.
1186
+ #
1187
+ # Instance Connect and SSM Session Manager both work by wrapping the
1188
+ # transport's connection handling, which has to happen before the
1189
+ # transport is first used.
1190
+ #
1191
+ # @param instance [Kitchen::Instance] the instance this driver serves
1192
+ # @return [self]
975
1193
  def finalize_config!(instance)
976
1194
  super
977
1195
 
@@ -991,6 +1209,18 @@ module Kitchen
991
1209
 
992
1210
  private
993
1211
 
1212
+ # Wrap the transport's `connection` method with Instance Connect setup.
1213
+ #
1214
+ # A pushed Instance Connect key expires after about a minute, so the key
1215
+ # is refreshed and the connection mode re-decided before every connection
1216
+ # rather than once at create time.
1217
+ #
1218
+ # Guarded against being applied twice: the override wraps the previous
1219
+ # method, so applying it again would wrap the wrapper and push the key
1220
+ # more than once per connection.
1221
+ #
1222
+ # @param instance [Kitchen::Instance] the instance whose transport to wrap
1223
+ # @return [void]
994
1224
  def instance_connect_setup_override(instance)
995
1225
  # Prevent double pushing of the SSH public keys
996
1226
  return if instance.transport.respond_to?(:instance_connect_override_applied)
@@ -1029,6 +1259,14 @@ module Kitchen
1029
1259
  instance.transport.define_singleton_method(:instance_connect_override_applied) { true }
1030
1260
  end
1031
1261
 
1262
+ # Wrap the InSpec verifier's `call` method with Instance Connect setup.
1263
+ #
1264
+ # InSpec builds its own SSH options rather than going through the Test
1265
+ # Kitchen transport, so it needs the proxy command or public DNS injected
1266
+ # separately from {#instance_connect_setup_override}.
1267
+ #
1268
+ # @param instance [Kitchen::Instance] the instance whose verifier to wrap
1269
+ # @return [void] a no-op unless the verifier is InSpec
1032
1270
  def instance_connect_setup_inspec_override(instance)
1033
1271
  # Only apply to InSpec verifier
1034
1272
  return unless instance.verifier.name.downcase == "inspec"
@@ -1117,6 +1355,13 @@ module Kitchen
1117
1355
  instance.verifier.define_singleton_method(:instance_connect_inspec_override_applied) { true }
1118
1356
  end
1119
1357
 
1358
+ # Prepare an instance for its first Instance Connect connection.
1359
+ #
1360
+ # Chooses between tunnelling through an Instance Connect endpoint and
1361
+ # connecting directly over public DNS, then pushes the SSH key.
1362
+ #
1363
+ # @param state [Hash] the instance state, updated in place
1364
+ # @return [void]
1120
1365
  def instance_connect_setup_ready(state)
1121
1366
  # Determine whether to use proxy command or direct SSH based on endpoint availability
1122
1367
  if instance_connect_endpoint_available?(state)
@@ -1133,6 +1378,17 @@ module Kitchen
1133
1378
  instance_connect_refresh_key(state)
1134
1379
  end
1135
1380
 
1381
+ # Push the SSH public key to the instance again.
1382
+ #
1383
+ # Instance Connect keys are accepted for roughly sixty seconds, so this
1384
+ # runs before each connection. A failure is warned about rather than
1385
+ # raised, because the key may still be valid from a previous push.
1386
+ #
1387
+ # @note Shells out to the AWS CLI rather than using the SDK client in
1388
+ # {Kitchen::Driver::Aws::InstanceConnect}.
1389
+ #
1390
+ # @param state [Hash] the instance state
1391
+ # @return [void] a no-op when no SSH key is known
1136
1392
  def instance_connect_refresh_key(state)
1137
1393
  # Extract public key from the key that was already set up
1138
1394
  key_path = state[:ssh_key] || instance.transport[:ssh_key]
@@ -1163,6 +1419,14 @@ module Kitchen
1163
1419
  end
1164
1420
  end
1165
1421
 
1422
+ # Configure SSH to tunnel through an Instance Connect endpoint.
1423
+ #
1424
+ # Used for instances with no public address: the AWS CLI opens a tunnel
1425
+ # that SSH is pointed at as a proxy command.
1426
+ #
1427
+ # @param state [Hash] the instance state, updated in place with
1428
+ # `:ssh_proxy_command` and `:instance_connect_config`
1429
+ # @return [void]
1166
1430
  def instance_connect_configure_ssh_proxy_command(state)
1167
1431
  info("[AWS EC2 Instance Connect] Configuring proxy command mode (tunnel)")
1168
1432
 
@@ -1198,6 +1462,15 @@ module Kitchen
1198
1462
  }
1199
1463
  end
1200
1464
 
1465
+ # Whether an Instance Connect endpoint can be used for this instance.
1466
+ #
1467
+ # A configured endpoint ID is trusted without a lookup. Otherwise the
1468
+ # instance's VPC is searched for a completed endpoint. Instance Connect
1469
+ # endpoints are not available in every region or to every IAM principal,
1470
+ # so a rejected lookup means "no endpoint" rather than an error.
1471
+ #
1472
+ # @param state [Hash] the instance state
1473
+ # @return [Boolean]
1201
1474
  def instance_connect_endpoint_available?(state)
1202
1475
  # If explicitly configured, respect that configuration
1203
1476
  return true if config[:instance_connect_endpoint_id]
@@ -1222,6 +1495,10 @@ module Kitchen
1222
1495
  end
1223
1496
  end
1224
1497
 
1498
+ # The VPC an instance belongs to.
1499
+ #
1500
+ # @param state [Hash] the instance state
1501
+ # @return [String, nil] the VPC ID, or nil when it cannot be determined
1225
1502
  def get_vpc_id_for_instance(state)
1226
1503
  # Get the instance details to find its VPC
1227
1504
  return unless state[:server_id]
@@ -1237,6 +1514,14 @@ module Kitchen
1237
1514
  end
1238
1515
  end
1239
1516
 
1517
+ # Configure SSH to connect straight to the instance's public DNS name.
1518
+ #
1519
+ # Used when no Instance Connect endpoint is available. When the instance
1520
+ # has no public DNS name there is nothing to switch to, so the existing
1521
+ # hostname is kept and a warning is logged.
1522
+ #
1523
+ # @param state [Hash] the instance state, updated in place
1524
+ # @return [void]
1240
1525
  def instance_connect_configure_direct_ssh(state)
1241
1526
  # For direct SSH, we need to ensure the hostname is the public DNS name
1242
1527
  # and configure SSH options appropriately
@@ -1261,6 +1546,15 @@ module Kitchen
1261
1546
  end
1262
1547
  end
1263
1548
 
1549
+ # The OpenSSH public key matching a private key.
1550
+ #
1551
+ # Prefers an adjacent `.pub` file, and derives the public half from the
1552
+ # private key otherwise -- keys created by {#create_key} are downloaded
1553
+ # from EC2 as a bare private key with no `.pub` alongside.
1554
+ #
1555
+ # @param private_key_path [String] path to the private key
1556
+ # @return [String] the public key in OpenSSH format
1557
+ # @raise [RuntimeError] when the key cannot be read or parsed
1264
1558
  def instance_connect_extract_public_key(private_key_path)
1265
1559
  public_key_path = "#{private_key_path}.pub"
1266
1560
 
@@ -1278,10 +1572,22 @@ module Kitchen
1278
1572
 
1279
1573
  # SSM Session Manager Support Methods
1280
1574
 
1575
+ # The SSM Session Manager helper.
1576
+ #
1577
+ # @return [Kitchen::Driver::Aws::SsmSessionManager]
1281
1578
  def ssm_session_manager
1282
1579
  @ssm_session_manager ||= Aws::SsmSessionManager.new(config, instance.logger)
1283
1580
  end
1284
1581
 
1582
+ # Wait for an instance to become reachable over SSM.
1583
+ #
1584
+ # The SSM agent registers itself some time after the instance boots, so
1585
+ # this polls for up to two minutes. A timeout is warned about rather than
1586
+ # raised: the usual cause is a missing IAM instance profile, and the
1587
+ # connection attempt itself gives a clearer error.
1588
+ #
1589
+ # @param state [Hash] the instance state
1590
+ # @return [void]
1285
1591
  def ssm_session_manager_setup_ready(state)
1286
1592
  info("[AWS SSM Session Manager] Setting up SSM Session Manager connection")
1287
1593
 
@@ -1314,6 +1620,14 @@ module Kitchen
1314
1620
  end
1315
1621
  end
1316
1622
 
1623
+ # Wrap the transport's `connection` method with SSM Session Manager setup.
1624
+ #
1625
+ # SSM connections work by pointing SSH at `aws ssm start-session` as a
1626
+ # proxy command, which needs the instance ID and so cannot be built until
1627
+ # the instance exists.
1628
+ #
1629
+ # @param instance [Kitchen::Instance] the instance whose transport to wrap
1630
+ # @return [void]
1317
1631
  def ssm_session_manager_setup_override(instance)
1318
1632
  # Prevent double setup
1319
1633
  return if instance.transport.respond_to?(:ssm_session_manager_override_applied)
@@ -1359,6 +1673,14 @@ module Kitchen
1359
1673
  instance.transport.define_singleton_method(:ssm_session_manager_override_applied) { true }
1360
1674
  end
1361
1675
 
1676
+ # Wrap the InSpec verifier's `call` method with SSM Session Manager setup.
1677
+ #
1678
+ # InSpec builds its own SSH options rather than going through the Test
1679
+ # Kitchen transport, so the proxy command has to be injected separately
1680
+ # from {#ssm_session_manager_setup_override}.
1681
+ #
1682
+ # @param instance [Kitchen::Instance] the instance whose verifier to wrap
1683
+ # @return [void] a no-op unless the verifier is InSpec
1362
1684
  def ssm_session_manager_setup_inspec_override(instance)
1363
1685
  # Only apply to InSpec verifier
1364
1686
  return unless instance.verifier.name.downcase == "inspec"
@@ -19,6 +19,6 @@
19
19
  module Kitchen
20
20
  module Driver
21
21
  # Version string for EC2 Test Kitchen driver
22
- EC2_VERSION = "3.22.3".freeze
22
+ EC2_VERSION = "3.22.4".freeze
23
23
  end
24
24
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kitchen-ec2
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.22.3
4
+ version: 3.22.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Test Kitchen Team
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-28 00:00:00.000000000 Z
11
+ date: 2026-08-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: aws-sdk-ec2