kitchen-ec2 3.22.9 → 3.23.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 54de858935bf91fcad58900283fd45042007c18d546797d098f29b955d177c87
4
- data.tar.gz: 95fc2485b796d0e87974e17de7b34dd858c08fdb2d2433cbae6975983cea1501
3
+ metadata.gz: 4b31b976c77faa84d3828219b396b07d52790a806d0b2a0fc72b95f8dabbe548
4
+ data.tar.gz: 52cb6998a147c6ca43e638f80a16f3b461f3ed25d7999695c42197a1900c34e1
5
5
  SHA512:
6
- metadata.gz: dad42aca368360dd2d11c58583d13360f34fa77925a0fd40ede0c97da97092361c74ba7b61401918d7c1ae33732806fe8cdd7ddb84b2d23024dc21a20a94a835
7
- data.tar.gz: 5e3a1ce54adf7425bce0c70816b212b0a2e8be36f35e9a1f8a58ebe971cfda2ace1c004e9dea91dc5d334021ba8df5f7541d1f891b1121021a9321f82d0f311c
6
+ metadata.gz: 0d57aa3da2611170b0e00e140e9d671c1a7585048020da316c1194d238e9c3fd41ef4ffc62ed1e4375d1862983220cd6c67e48ca25f98777adc677e0d6987f87
7
+ data.tar.gz: 68faeb3e26106d8df47a4896b7d6f1ef18c3c30cbbfd2d12e1cac6dcd2dddacd208f60d92493647ec19fba347e50f14691c279ad33999bf109a5ca949817e7cc
@@ -88,38 +88,36 @@ module Kitchen
88
88
 
89
89
  if config[:security_group_ids].nil? && config[:security_group_filter]
90
90
  # => Grab the VPC in the case a Subnet ID rather than Filter was set
91
- vpc_id ||= client.describe_subnets(subnet_ids: [config[:subnet_id]]).subnets[0].vpc_id
91
+ vpc_id ||= security_group_search_vpc_id(client)
92
92
  security_groups = []
93
93
  filters = [config[:security_group_filter]].flatten
94
94
  filters.each do |sg_filter|
95
- r = {}
95
+ # Built up rather than assigned, so that a filter carrying both a
96
+ # name and a tag searches on both. Assigning meant the tag
97
+ # replaced the name outright, silently widening the search to
98
+ # whatever else carried that tag.
99
+ criteria = []
96
100
  if sg_filter[:name]
97
- r[:filters] = [
98
- {
99
- name: "group-name",
100
- values: [sg_filter[:name]],
101
- },
102
- {
103
- name: "vpc-id",
104
- values: [vpc_id],
105
- },
106
- ]
101
+ criteria << { name: "group-name", values: [sg_filter[:name]] }
107
102
  end
108
-
109
103
  if sg_filter[:tag]
110
- r[:filters] = [
111
- {
112
- name: "tag:#{sg_filter[:tag]}",
113
- values: [sg_filter[:value]],
114
- },
115
- {
116
- name: "vpc-id",
117
- values: [vpc_id],
118
- },
119
- ]
104
+ criteria << { name: "tag:#{sg_filter[:tag]}", values: [sg_filter[:value]] }
105
+ end
106
+
107
+ # Refused rather than sent: describe_security_groups with no
108
+ # filters returns every security group in the region, and all of
109
+ # them were then attached to the instance.
110
+ if criteria.empty?
111
+ raise "A security_group_filter needs a `name` or a `tag`, but " \
112
+ "#{sg_filter.inspect} has neither."
120
113
  end
121
114
 
122
- security_group = client.describe_security_groups(r).security_groups
115
+ # Only when a VPC is known. A name or tag has already narrowed the
116
+ # search, so leaving this off is not the unfiltered request the
117
+ # check above exists to prevent.
118
+ criteria << { name: "vpc-id", values: [vpc_id] } if vpc_id
119
+
120
+ security_group = client.describe_security_groups(filters: criteria).security_groups
123
121
 
124
122
  if security_group.any?
125
123
  security_group.each { |sg| security_groups.push(sg.group_id) }
@@ -248,6 +246,46 @@ module Kitchen
248
246
  i
249
247
  end
250
248
 
249
+ # The VPC to look for security groups in.
250
+ #
251
+ # Security group names are unique only within a VPC, so the search is
252
+ # scoped to the VPC the instance will launch into. Which VPC that is
253
+ # depends on how the subnet was chosen:
254
+ #
255
+ # - a named `subnet_id` decides it, so the subnet is described for it
256
+ # - no subnet at all means EC2 launches into the account's default VPC,
257
+ # so that is what gets searched
258
+ #
259
+ # The second case used to describe a subnet with an ID of nil and then
260
+ # read `vpc_id` off the empty result, so a `security_group_filter` on
261
+ # its own -- a documented combination, and the natural one in a default
262
+ # VPC account -- died with `undefined method 'vpc_id' for nil` before
263
+ # anything was launched.
264
+ #
265
+ # An account with no default VPC returns nil, and the caller then
266
+ # searches on the name or tag alone.
267
+ #
268
+ # @param client [Aws::EC2::Client] the client to query with
269
+ # @return [String, nil] the VPC ID, or nil when there is no default VPC
270
+ # @raise [RuntimeError] when a named subnet does not exist, which would
271
+ # otherwise surface as the same nil dereference
272
+ def security_group_search_vpc_id(client)
273
+ unless config[:subnet_id]
274
+ default_vpc = client.describe_vpcs(
275
+ filters: [{ name: "isDefault", values: %w{true} }]
276
+ ).vpcs.first
277
+
278
+ return default_vpc&.vpc_id
279
+ end
280
+
281
+ subnet = client.describe_subnets(subnet_ids: [config[:subnet_id]]).subnets.first
282
+ unless subnet
283
+ raise "Subnet #{config[:subnet_id]} not found while resolving security_group_filter."
284
+ end
285
+
286
+ subnet.vpc_id
287
+ end
288
+
251
289
  # The user data script, base64 encoded as EC2 requires.
252
290
  #
253
291
  # The configured value is treated as a file path when it names an
@@ -98,6 +98,29 @@ module Kitchen
98
98
  search
99
99
  end
100
100
 
101
+ # Sort images newest release first, keeping backports images last.
102
+ #
103
+ # Debian publishes a backports image alongside each release, from the
104
+ # same account and under the same "debian-<release>-" prefix,
105
+ # differing only by the word "backports" in the name:
106
+ #
107
+ # debian-12-backports-amd64-20260821-2577
108
+ # debian-12-amd64-20260821-2577
109
+ #
110
+ # It runs the backports kernel rather than the release's own -- 6.12
111
+ # against 6.1 for Debian 12 -- and is often published minutes after
112
+ # its plain counterpart, so a tie broken on creation date handed
113
+ # every Debian platform the backports image.
114
+ #
115
+ # This is a preference rather than a filter, so a release with only
116
+ # backports images published is still selectable.
117
+ #
118
+ # @param images [Array<Aws::EC2::Image>] the images to sort
119
+ # @return [Array<Aws::EC2::Image>] the images, newest release first
120
+ def sort_by_version(images)
121
+ prefer(super) { |image| !image.name.include?("backports") }
122
+ end
123
+
101
124
  # Detect this platform from an EC2 image.
102
125
  #
103
126
  # Matching is done on the image name, which is the only reliable signal
@@ -34,6 +34,24 @@ module Kitchen
34
34
  "ec2-user"
35
35
  end
36
36
 
37
+ # The AWS account Amazon publishes its macOS AMIs under.
38
+ #
39
+ # @return [String]
40
+ MACOS_OWNER_ID = "628277914472".freeze
41
+
42
+ # EC2's architecture values for Mac instances, keyed by the name the
43
+ # platform string uses.
44
+ #
45
+ # EC2 reports a Mac image's architecture as "x86_64_mac" or
46
+ # "arm64_mac", never the bare "x86_64" or "arm64" every other
47
+ # platform uses, so an unmapped value matches nothing at all.
48
+ #
49
+ # @return [Hash{String => String}]
50
+ MAC_ARCHITECTURES = {
51
+ "arm64" => "arm64_mac",
52
+ "x86_64" => "x86_64_mac",
53
+ }.freeze
54
+
37
55
  # EC2 image filters that select Amazon's macOS AMIs, which run only on dedicated Mac hosts.
38
56
  #
39
57
  # A filter is added for {StandardPlatform#architecture} only when one was
@@ -44,11 +62,12 @@ module Kitchen
44
62
  # @see StandardPlatform#find_image
45
63
  def image_search
46
64
  search = {
47
- "owner-id" => "100343932686",
65
+ "owner-id" => MACOS_OWNER_ID,
48
66
  "name" => version ? "amzn-ec2-macos-#{version}*" : "amzn-ec2-macos-*",
49
67
  }
50
- search["architecture"] = architecture if architecture
51
- search["architecture"] = "arm64_mac" if architecture == "arm64"
68
+ if architecture
69
+ search["architecture"] = MAC_ARCHITECTURES.fetch(architecture, architecture)
70
+ end
52
71
  search
53
72
  end
54
73
 
@@ -176,7 +176,7 @@ module Kitchen
176
176
  # Detect platform from an image.
177
177
  #
178
178
  # @param driver [Kitchen::Driver::Ec2] The driver.
179
- # @param image [Aws::Ec2::Image] The EC2 Image object.
179
+ # @param image [Aws::EC2::Image] The EC2 Image object.
180
180
  #
181
181
  # @return [Kitchen::Driver::Aws::StandardPlatform]
182
182
  #
@@ -223,9 +223,9 @@ module Kitchen
223
223
  # Used by the default find_image. The default version calls platform_from_image()
224
224
  # on each image, and interprets the versions as floats (7 < 7.1 < 8).
225
225
  #
226
- # @param images [Array[Aws::Ec2::Image]] The list of images to sort
226
+ # @param images [Array<Aws::EC2::Image>] The list of images to sort
227
227
  #
228
- # @return [Array[Aws::Ec2::Image]] A sorted list.
228
+ # @return [Array<Aws::EC2::Image>] A sorted list.
229
229
  #
230
230
  def sort_by_version(images)
231
231
  # 7.1 -> [ img1, img2, img3 ]
@@ -246,6 +246,7 @@ module Kitchen
246
246
  # preferences.
247
247
  #
248
248
  # @param images [Array<Aws::EC2::Image>] the images to reorder
249
+ # @param block [Proc] the predicate each image is partitioned by
249
250
  # @yieldparam image [Aws::EC2::Image] an image to test
250
251
  # @yieldreturn [Boolean] true when the image is preferred
251
252
  # @return [Array<Aws::EC2::Image>] preferred images first
@@ -19,6 +19,7 @@
19
19
  require "fileutils" unless defined?(FileUtils)
20
20
  require "sshkey" unless defined?(SSHKey)
21
21
  require "benchmark" unless defined?(Benchmark)
22
+ require "open3" unless defined?(Open3)
22
23
  require "json" unless defined?(JSON)
23
24
  require "kitchen"
24
25
  require_relative "ec2_version"
@@ -229,6 +230,19 @@ module Kitchen
229
230
  end
230
231
  end
231
232
 
233
+ # A placement group is named either by ID or by name, never both: EC2
234
+ # rejects a RunInstances call carrying the pair. The payload generator
235
+ # therefore only applies each one when the other is absent, which means
236
+ # setting both silently drops both and the instance launches in no
237
+ # placement group at all, with nothing in the output to say so.
238
+ validations[:placement] = lambda do |_attr, val, _driver|
239
+ if val.is_a?(Hash) && val[:group_id] && val[:group_name]
240
+ warn "Cannot set both 'group_id' and 'group_name' under 'placement'. " \
241
+ "A placement group is identified by one or the other, so please set only one."
242
+ exit!
243
+ end
244
+ end
245
+
232
246
  # empty keys cause failures when tagging and they make no sense
233
247
  validations[:tags] = lambda do |_attr, val, _driver|
234
248
  # if someone puts the tags each on their own line it's an array not a hash
@@ -242,6 +256,72 @@ module Kitchen
242
256
 
243
257
  # Create an EC2 instance and wait until it can be connected to.
244
258
  #
259
+ # Configuration that cannot possibly work is rejected first, before
260
+ # anything billable is launched; {#launch_instance} does the real work.
261
+ #
262
+ # @param state [Hash] the instance state, updated in place with
263
+ # `:server_id`, `:hostname` and any auto-created credentials
264
+ # @return [void]
265
+ # @raise [Kitchen::UserError] when the platform is misconfigured such
266
+ # that the run could not succeed
267
+ def create(state)
268
+ return if state[:server_id]
269
+
270
+ # Deliberately checked here rather than inside #launch_instance: that
271
+ # method rewrites every exception it sees into an "is this AMI available
272
+ # in this region" message, which would bury this one.
273
+ assert_powershell_shell_type!
274
+
275
+ launch_instance(state)
276
+ end
277
+
278
+ # Refuse to launch a Windows instance that would be driven with a Bourne
279
+ # shell type.
280
+ #
281
+ # Test Kitchen infers `shell_type` from the platform *name*, not from
282
+ # `os_type`, so a Windows platform named something that does not begin
283
+ # with "windows" silently ends up as a Bourne host. The provisioner then
284
+ # generates Bourne commands, WinRM executes them in PowerShell, and the
285
+ # run fails deep into converge with an error that points nowhere near the
286
+ # real cause -- typically a `New-Item` complaint that the sandbox
287
+ # directory already exists.
288
+ #
289
+ # Failing here costs the user nothing; letting it through costs them a
290
+ # billable instance and a confusing converge failure.
291
+ #
292
+ # @raise [Kitchen::UserError] when the platform is Windows but its shell
293
+ # type is not PowerShell
294
+ # @return [void]
295
+ # @see https://github.com/test-kitchen/kitchen-ec2/issues/621
296
+ def assert_powershell_shell_type!
297
+ return unless windows_os?
298
+ return if powershell_shell?
299
+
300
+ shell_type = instance.platform.respond_to?(:shell_type) ? instance.platform.shell_type : nil
301
+
302
+ raise Kitchen::UserError, <<~MESSAGE
303
+ Platform '#{instance.platform.name}' sets os_type 'windows' but its shell_type is '#{shell_type}'.
304
+
305
+ Test Kitchen infers shell_type from the platform name, so a Windows platform
306
+ whose name does not begin with 'windows' is treated as a Bourne shell host.
307
+ Provisioner commands would be generated as Bourne syntax and then executed by
308
+ PowerShell over WinRM, failing during converge with an unrelated error such as
309
+ 'Cannot create ... because a file or directory with the same name already exists'.
310
+
311
+ Set shell_type explicitly on the platform:
312
+
313
+ platforms:
314
+ - name: #{instance.platform.name}
315
+ os_type: windows
316
+ shell_type: powershell
317
+
318
+ Renaming the platform to start with 'windows' also works, as Test Kitchen then
319
+ infers both os_type and shell_type from the name.
320
+ MESSAGE
321
+ end
322
+
323
+ # Request the instance and wait until it can be connected to.
324
+ #
245
325
  # Auto-creates a security group and key pair when none were configured,
246
326
  # allocates a dedicated host if `tenancy: host` requires one, requests
247
327
  # either an on-demand or a spot instance, then waits for the instance to
@@ -256,9 +336,7 @@ module Kitchen
256
336
  # @return [void]
257
337
  # @raise [Kitchen::ActionFailed] wrapping whatever went wrong, after
258
338
  # cleaning up
259
- def create(state)
260
- return if state[:server_id]
261
-
339
+ def launch_instance(state)
262
340
  update_username(state)
263
341
 
264
342
  info(Kitchen::Util.outdent!(<<-END)) unless config[:skip_cost_warning]
@@ -295,7 +373,8 @@ module Kitchen
295
373
  exit!
296
374
  end
297
375
 
298
- allocate_host unless host_available?
376
+ # Remembered so that destroy releases this host and no other.
377
+ state[:allocated_host_id] = allocate_host unless host_available?
299
378
 
300
379
  info("Auto placement on one dedicated host out of: #{hosts_with_capacity.map(&:host_id).join(", ")}")
301
380
  end
@@ -374,7 +453,10 @@ module Kitchen
374
453
  # @see https://github.com/test-kitchen/kitchen-ec2/issues/606
375
454
  def create_failure_message(error)
376
455
  message = "Failed to create the EC2 instance: #{error.class}: #{error.message}"
377
- return message unless image_related_error?(error)
456
+ # The hint names the image, so it has nothing to say when there is no
457
+ # image to name. Without this it rendered as "Check that image exists"
458
+ # on exactly the failure where no image was ever resolved.
459
+ return message unless config[:image_id] && image_related_error?(error)
378
460
 
379
461
  "#{message} Check that image #{config[:image_id]} exists and is available " \
380
462
  "in region #{config[:region]}."
@@ -382,14 +464,19 @@ module Kitchen
382
464
 
383
465
  # Whether a failure is about the AMI rather than something else entirely.
384
466
  #
385
- # EC2 reports every image problem with a code beginning "InvalidAMI"; the
386
- # message check catches errors raised by the driver itself, which are
387
- # plain strings with no code attached.
467
+ # EC2 reports every image problem with a code beginning "InvalidAMI". The
468
+ # message check is for errors raised by the driver itself, which are
469
+ # plain strings with no code attached -- but it only applies to those,
470
+ # because plenty of AWS errors merely *mention* the AMI while being about
471
+ # something else. The clearest example is an instance type whose
472
+ # architecture does not match the image's: its message names the AMI
473
+ # twice, and the old check duly told the user to go and check whether an
474
+ # image that exists, exists.
388
475
  #
389
476
  # @param error [Exception] the underlying failure
390
477
  # @return [Boolean]
391
478
  def image_related_error?(error)
392
- return true if error.respond_to?(:code) && error.code.to_s.start_with?("InvalidAMI")
479
+ return error.code.to_s.start_with?("InvalidAMI") if error.respond_to?(:code)
393
480
 
394
481
  error.message.to_s.match?(/\bAMI\b/i)
395
482
  end
@@ -414,10 +501,17 @@ module Kitchen
414
501
  warn("Received #{e}, instance was probably already destroyed. Ignoring")
415
502
  end
416
503
  end
417
- # If we are going to clean up an automatic security group, we need
418
- # to wait for the instance to shut down. This slightly breaks the
419
- # subsystem encapsulation, sorry not sorry.
420
- if state[:auto_security_group_id] && server && ec2.instance_exists?(state[:server_id])
504
+ # Two cleanups below cannot succeed while the instance is still
505
+ # shutting down, so either of them means waiting termination out:
506
+ # an auto-created security group cannot be deleted while an instance
507
+ # still references it, and a dedicated host goes on listing a
508
+ # terminating instance, which blocks its release.
509
+ #
510
+ # The host case used to be missing, so a run that supplied its own
511
+ # `security_group_ids` skipped the wait, found the host still
512
+ # occupied, and silently left it allocated and billing.
513
+ if (state[:auto_security_group_id] || state[:allocated_host_id]) &&
514
+ server && ec2.instance_exists?(state[:server_id])
421
515
  wait_log = proc do |attempts|
422
516
  c = attempts * config[:retryable_sleep]
423
517
  t = config[:retryable_tries] * config[:retryable_sleep]
@@ -438,11 +532,88 @@ module Kitchen
438
532
  delete_security_group(state)
439
533
  delete_key(state)
440
534
 
441
- # Clean up dedicated hosts matching instance_type and unused (if allowed)
535
+ # Release the dedicated host this instance's create allocated, if it
536
+ # allocated one and nothing else is left running on it.
537
+ #
538
+ # Only that host. Dedicated hosts are a shared pool -- create places
539
+ # onto any managed host with room rather than always allocating, so
540
+ # most runs allocate nothing -- and releasing every empty managed host
541
+ # tore down hosts belonging to other suites, including one allocated
542
+ # seconds earlier by a concurrent run whose instance had not launched
543
+ # onto it yet.
442
544
  return unless config[:tenancy] == "host" && allow_deallocate_host?
443
545
 
444
- empty_hosts = hosts_with_capacity.select { |host| host_unused?(host) }
445
- empty_hosts.each { |host| deallocate_host(host.host_id) }
546
+ host_id = state.delete(:allocated_host_id)
547
+ return unless host_id
548
+
549
+ host = host_for_id(host_id)
550
+ # Already gone, or never usable: nothing to release either way.
551
+ return if host.nil? || host.state != "available"
552
+
553
+ unless host_unused?(host)
554
+ # Test Kitchen deletes the state file once destroy returns, taking
555
+ # the host ID with it, so there is no later run that could pick this
556
+ # up -- say so loudly and give the command to finish the job.
557
+ error("Dedicated host #{host_id} still has instances on it and was not released. " \
558
+ "A dedicated host bills from allocation until it is released. Release it with: " \
559
+ "aws ec2 release-hosts --region #{config[:region]} --host-ids #{host_id}")
560
+ return
561
+ end
562
+
563
+ deallocate_host(host_id)
564
+ end
565
+
566
+ # EC2 instance states in which the instance still exists as a resource.
567
+ #
568
+ # "shutting-down" and "terminated" are left out deliberately. Neither can
569
+ # be brought back and neither leaves anything to destroy, so counting
570
+ # them as live would defeat the point of asking.
571
+ #
572
+ # @return [Array<String>]
573
+ LIVE_INSTANCE_STATES = %w{pending running stopping stopped}.freeze
574
+
575
+ # Whether the instance Test Kitchen recorded is still there.
576
+ #
577
+ # Answers `kitchen list --live` by asking EC2 rather than trusting the
578
+ # state file, which is the whole point: the state file records what Test
579
+ # Kitchen last did, not what survived. An instance terminated in the
580
+ # console, reaped by an account policy, or orphaned by a run that was
581
+ # killed mid-create all leave a state file claiming the instance exists.
582
+ #
583
+ # Kept to a single describe call, since `kitchen list --live` makes one
584
+ # of these per instance.
585
+ #
586
+ # @param state [Hash] the instance state
587
+ # @return [Hash] status data, normalized by Kitchen::Instance
588
+ # @see https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_InstanceState.html
589
+ def status(state)
590
+ unless state[:server_id]
591
+ return status_report(
592
+ live: false,
593
+ instance_state: "not_created",
594
+ message: "No EC2 instance has been created for this suite yet"
595
+ )
596
+ end
597
+
598
+ instance_state = ec2.get_instance(state[:server_id]).state.name
599
+
600
+ status_report(
601
+ live: LIVE_INSTANCE_STATES.include?(instance_state),
602
+ instance_state: instance_state,
603
+ resource_id: state[:server_id],
604
+ message: "EC2 instance #{state[:server_id]} is #{instance_state}"
605
+ )
606
+ rescue ::Aws::EC2::Errors::InvalidInstanceIDNotFound
607
+ # The reconciliation this check exists for: Test Kitchen still believes
608
+ # it has an instance, and EC2 has never heard of it.
609
+ status_report(
610
+ live: false,
611
+ instance_state: "not_found",
612
+ resource_id: state[:server_id],
613
+ message: "EC2 does not know instance #{state[:server_id]}. It was terminated " \
614
+ "outside Test Kitchen, or has aged out of EC2's terminated instance list. " \
615
+ "Run `kitchen destroy` to clear the stale state."
616
+ )
446
617
  end
447
618
 
448
619
  # The EC2 image this instance will be created from.
@@ -458,6 +629,14 @@ module Kitchen
458
629
  @image = ec2.resource.image(config[:image_id])
459
630
  show_chosen_image
460
631
 
632
+ elsif searched_for_image?
633
+ # A search ran and matched nothing. Saying "specify image_id or
634
+ # image_search" here sent people to set an option they had already
635
+ # set, or that the platform sets for them -- the real problem is
636
+ # that the filters matched no image in this region.
637
+ raise "The image search for #{desired_platform || instance.platform.name} matched no " \
638
+ "image in region #{config[:region]}. Set image_id to an AMI, or image_search to " \
639
+ "filters that match one."
461
640
  else
462
641
  raise "Neither image_id nor an image_search specified for instance #{instance.name}!" \
463
642
  " Please specify one or the other."
@@ -466,16 +645,33 @@ module Kitchen
466
645
  @image
467
646
  end
468
647
 
648
+ # Whether an image search ran and came back empty.
649
+ #
650
+ # Distinguishes "there was nothing to search for" -- an unrecognized
651
+ # platform name and no `image_search` -- from "the search matched
652
+ # nothing", which are different mistakes with different fixes.
653
+ #
654
+ # @return [Boolean]
655
+ def searched_for_image?
656
+ !config[:image_search].nil? || !desired_platform.nil?
657
+ end
658
+
469
659
  # The instance type to use when the user did not choose one.
470
660
  #
661
+ # An instance type runs one processor architecture and EC2 rejects a
662
+ # RunInstances call pairing it with an image built for another, so the
663
+ # default has to follow the image: t4g is the Graviton counterpart of
664
+ # t3. Picking t3.micro for every HVM image made every arm64 platform --
665
+ # "ubuntu-24.04-arm64" and friends, an architecture this driver parses
666
+ # and searches for -- fail to launch outright.
667
+ #
471
668
  # t3 instances require a hardware-virtualized image, so a paravirtual
472
669
  # image falls back to the older t1 family.
473
670
  #
474
- # @return [String] a free-tier instance type
671
+ # @return [String] a free-tier instance type matching the image
475
672
  def default_instance_type
476
673
  @instance_type ||= if image && image.virtualization_type == "hvm"
477
- info("instance_type not specified. Using free tier t3.micro instance ...")
478
- "t3.micro"
674
+ hvm_default_instance_type
479
675
  else
480
676
  info("instance_type not specified. Using free tier t1.micro instance since" \
481
677
  " image is paravirtual (pick an hvm image to use the superior t3.micro!) ...")
@@ -483,6 +679,25 @@ module Kitchen
483
679
  end
484
680
  end
485
681
 
682
+ # The free-tier instance type matching a hardware-virtualized image.
683
+ #
684
+ # Only arm64 is special-cased. EC2's Mac architectures ("arm64_mac" and
685
+ # "x86_64_mac") are deliberately not defaulted: they run only on
686
+ # dedicated Mac hosts, which carry a 24-hour minimum allocation, so
687
+ # guessing one would be an expensive surprise rather than a convenience.
688
+ #
689
+ # @return [String] the instance type to launch
690
+ def hvm_default_instance_type
691
+ if image.architecture == "arm64"
692
+ info("instance_type not specified. Using free tier t4g.micro instance" \
693
+ " since image is arm64 ...")
694
+ "t4g.micro"
695
+ else
696
+ info("instance_type not specified. Using free tier t3.micro instance ...")
697
+ "t3.micro"
698
+ end
699
+ end
700
+
486
701
  # The platform detected from the image actually being used.
487
702
  #
488
703
  # This can differ from {#desired_platform}: the user asks for "ubuntu" and
@@ -794,6 +1009,7 @@ module Kitchen
794
1009
  # @param server [Aws::EC2::Instance] the instance to wait on
795
1010
  # @param state [Hash] the instance state
796
1011
  # @param status_msg [String] what is being waited for, for log messages
1012
+ # @param block [Proc] the readiness check polled against the instance
797
1013
  # @yieldparam aws_instance [Aws::EC2::Instance] the instance being polled
798
1014
  # @yieldreturn [Boolean] true when the wait is over
799
1015
  # @return [void]
@@ -954,20 +1170,41 @@ module Kitchen
954
1170
  # @return [String] a PowerShell script wrapped in `<powershell>` tags
955
1171
  def default_windows_user_data
956
1172
  base_script = Kitchen::Util.outdent!(<<-EOH)
957
- $OSVersion = (get-itemproperty -Path "HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion" -Name ProductName).ProductName
958
- If($OSVersion.contains('2016') -Or $OSVersion.contains('2019') -Or $OSVersion -eq 'Windows Server Datacenter') {
959
- New-Item -ItemType Directory -Force -Path 'C:\\ProgramData\\Amazon\\EC2-Windows\\Launch\\Log'
960
- $logfile='C:\\ProgramData\\Amazon\\EC2-Windows\\Launch\\Log\\kitchen-ec2.log'
961
- # EC2Launch doesn't init extra disks by default
962
- C:\\ProgramData\\Amazon\\EC2-Windows\\Launch\\Scripts\\InitializeDisks.ps1
963
- } Else {
964
- New-Item -ItemType Directory -Force -Path 'C:\\Program Files\\Amazon\\Ec2ConfigService\\Logs'
965
- $logfile='C:\\Program Files\\Amazon\\Ec2ConfigService\\Logs\\kitchen-ec2.log'
966
- }
967
-
968
- # Logfile fail-safe in case the directory does not exist
1173
+ # Log where the installed launch agent already logs, chosen by looking
1174
+ # for it rather than by matching the OS against known release names.
1175
+ # Writing into the directory of an agent that is not installed would
1176
+ # invent a misleading empty tree.
1177
+ $logdir = If (Test-Path 'C:\\ProgramData\\Amazon\\EC2Launch') {
1178
+ 'C:\\ProgramData\\Amazon\\EC2Launch\\log'
1179
+ } ElseIf (Test-Path 'C:\\ProgramData\\Amazon\\EC2-Windows\\Launch') {
1180
+ 'C:\\ProgramData\\Amazon\\EC2-Windows\\Launch\\Log'
1181
+ } ElseIf (Test-Path 'C:\\Program Files\\Amazon\\Ec2ConfigService') {
1182
+ 'C:\\Program Files\\Amazon\\Ec2ConfigService\\Logs'
1183
+ } Else {
1184
+ Join-Path $env:ProgramData 'Amazon\\kitchen-ec2'
1185
+ }
1186
+ New-Item -ItemType Directory -Force -Path $logdir | Out-Null
1187
+ $logfile = Join-Path $logdir 'kitchen-ec2.log'
969
1188
  New-Item $logfile -Type file -Force
970
1189
 
1190
+ # Extra EBS volumes are attached but left uninitialized: no launch
1191
+ # agent partitions them by default, on any release. Done with the
1192
+ # storage cmdlets rather than by calling a particular agent's script,
1193
+ # so it does not matter which agent is installed. Only RAW disks are
1194
+ # touched, so a volume that already carries a filesystem is never
1195
+ # reformatted.
1196
+ #
1197
+ # GPT, not MBR: an MBR disk cannot address beyond 2 TiB, and
1198
+ # Initialize-Disk does not fail on a larger one -- it caps it, so a
1199
+ # 2600 GB volume came up as a 2 TiB filesystem with the remainder
1200
+ # unreachable and still billed. GPT is supported by every Windows
1201
+ # release this driver can launch.
1202
+ "Initializing any uninitialized volumes" >> $logfile
1203
+ Get-Disk | Where-Object PartitionStyle -eq 'RAW' |
1204
+ Initialize-Disk -PartitionStyle GPT -PassThru |
1205
+ New-Partition -AssignDriveLetter -UseMaximumSize |
1206
+ Format-Volume -FileSystem NTFS -Confirm:$false >> $logfile
1207
+
971
1208
  # Allow script execution
972
1209
  Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Force
973
1210
  #PS Remoting and & winrm.cmd basic config
@@ -980,7 +1217,6 @@ module Kitchen
980
1217
  & winrm.cmd set winrm/config '@{MaxTimeoutms="1800000"}' >> $logfile
981
1218
  & winrm.cmd set winrm/config/winrs '@{MaxMemoryPerShellMB="1024"}' >> $logfile
982
1219
  & winrm.cmd set winrm/config/winrs '@{MaxShellsPerUser="50"}' >> $logfile
983
- & winrm.cmd set winrm/config/winrs '@{MaxMemoryPerShellMB="1024"}' >> $logfile
984
1220
  #Firewall Config
985
1221
  & netsh advfirewall firewall set rule name="Windows Remote Management (HTTP-In)" profile=public protocol=tcp localport=5985 remoteip=localsubnet new remoteip=any >> $logfile
986
1222
  Set-ItemProperty -Name LocalAccountTokenFilterPolicy -Path HKLM:\\software\\Microsoft\\Windows\\CurrentVersion\\Policies\\system -Value 1
@@ -1267,6 +1503,28 @@ module Kitchen
1267
1503
 
1268
1504
  private
1269
1505
 
1506
+ # Build a status hash in the shape Kitchen::Instance normalizes.
1507
+ #
1508
+ # `checked_at` is stamped here rather than left out so that the timestamp
1509
+ # reflects when EC2 was actually asked, not when the answer was rendered.
1510
+ #
1511
+ # @param live [Boolean] whether the instance still exists
1512
+ # @param instance_state [String] the EC2 state name, or a driver-level
1513
+ # one such as "not_created" for the cases EC2 was never asked about
1514
+ # @param message [String] a human-readable explanation
1515
+ # @param resource_id [String, nil] the EC2 instance ID, when there is one
1516
+ # @return [Hash]
1517
+ def status_report(live:, instance_state:, message:, resource_id: nil)
1518
+ {
1519
+ live: live,
1520
+ state: instance_state,
1521
+ source: "driver",
1522
+ resource_id: resource_id,
1523
+ message: message,
1524
+ checked_at: Time.now.utc.iso8601,
1525
+ }
1526
+ end
1527
+
1270
1528
  # Wrap the transport's `connection` method with Instance Connect setup.
1271
1529
  #
1272
1530
  # A pushed Instance Connect key expires after about a minute, so the key
@@ -1620,6 +1878,9 @@ module Kitchen
1620
1878
  return File.read(public_key_path).strip
1621
1879
  end
1622
1880
 
1881
+ public_key = instance_connect_public_key_via_ssh_keygen(private_key_path)
1882
+ return public_key if public_key
1883
+
1623
1884
  begin
1624
1885
  key = SSHKey.new(File.read(private_key_path))
1625
1886
  key.ssh_public_key
@@ -1628,6 +1889,35 @@ module Kitchen
1628
1889
  end
1629
1890
  end
1630
1891
 
1892
+ # Derive the public half of a private key with ssh-keygen.
1893
+ #
1894
+ # OpenSSH reads every key type EC2 can create. The sshkey gem handles
1895
+ # only RSA and DSA, and raises "Neither PUB key nor PRIV key" on an
1896
+ # ed25519 key -- which is exactly what `aws_ssh_key_type: ed25519`
1897
+ # produces, so that documented setting could not be combined with
1898
+ # Instance Connect at all.
1899
+ #
1900
+ # Falling back to the gem rather than requiring ssh-keygen keeps the RSA
1901
+ # default working where OpenSSH is not installed.
1902
+ #
1903
+ # @param private_key_path [String] path to the private key
1904
+ # @return [String, nil] the public key in OpenSSH format, or nil when
1905
+ # ssh-keygen is unavailable or could not read the key
1906
+ def instance_connect_public_key_via_ssh_keygen(private_key_path)
1907
+ output, status = Open3.capture2e("ssh-keygen", "-y", "-f", private_key_path)
1908
+ return output.strip if status.success?
1909
+
1910
+ debug("ssh-keygen could not read #{private_key_path}: #{output.strip}")
1911
+ nil
1912
+ # ::StandardError, not StandardError: this file is nested inside `module
1913
+ # Kitchen`, which defines Kitchen::StandardError. An unqualified constant
1914
+ # resolves to that one, letting the Errno::ENOENT raised by a missing
1915
+ # ssh-keygen escape the check meant to detect it.
1916
+ rescue ::StandardError => e
1917
+ debug("Could not run ssh-keygen: #{e.message}")
1918
+ nil
1919
+ end
1920
+
1631
1921
  # SSM Session Manager Support Methods
1632
1922
 
1633
1923
  # The SSM Session Manager helper.
@@ -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.9".freeze
22
+ EC2_VERSION = "3.23.1".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.9
4
+ version: 3.23.1
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-08-23 00:00:00.000000000 Z
11
+ date: 2026-10-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: aws-sdk-ec2