kitchen-ec2 3.20.0 → 3.22.0

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: f47ffd935db82650266d977423d84a3361466c34385adb49dcb782ce8f584b4d
4
- data.tar.gz: 11a0789de1befe3293786fe5d4dcd305cda95517bdc5195738bffb1eefede547
3
+ metadata.gz: 3baf7353ac946ddb0ad70a816dc36b0017afaddddb8392bd8a26e0e2f2afbbe4
4
+ data.tar.gz: 53f26dec487092acfe0e9b8bf495e7727e161b43b0b45085e92391a85fc7efae
5
5
  SHA512:
6
- metadata.gz: 37f1555b120b84df599b4c3378c8ac4dc49ca485d08fdaa491c3ffa8ef11e175c4d1cfad534f8529eec4f71683e276062ed4454116279de82ab9ce3638d38426
7
- data.tar.gz: b3c8845339ffc8f823881c4e336114ddd94139b193a192e5cef0fb0e25cd50b57f7bf69a03735f727d1407937527389337ef75aaa609339f891f6fc48f961a82
6
+ metadata.gz: a28a41f6067d48c4f233963a40e0b96756f06c30096f0dc0cded285f11b2c6f08c31f0a52762c89e26f223512304655ec807bfc0f20700150dfe01c0e2c489c4
7
+ data.tar.gz: d17ba3bb0ed6bba7744e4f0b8ebca626bb26fbfd11e37a00ce243a64e91a51d7df69af6a2f238bf13fe5a88de699b2248cb3fa2c3d014468d4ce9e2ac13f6bb5
@@ -0,0 +1,44 @@
1
+ #
2
+ # Author:: Alex Kokkinos
3
+ #
4
+ # Copyright:: 2025, Alex Kokkinos
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # https://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ require "aws-sdk-ec2instanceconnect"
18
+
19
+ module Kitchen
20
+ module Driver
21
+ class Aws
22
+ class InstanceConnect
23
+ def initialize(config, logger)
24
+ @config = config
25
+ @logger = logger
26
+ @client = ::Aws::EC2InstanceConnect::Client.new(region: config[:region])
27
+ end
28
+
29
+ def send_ssh_public_key(instance_id, username, public_key)
30
+ @logger.info("Sending SSH public key to instance #{instance_id} for user #{username}")
31
+
32
+ @client.send_ssh_public_key({
33
+ instance_id: instance_id,
34
+ instance_os_user: username,
35
+ ssh_public_key: public_key,
36
+ availability_zone: @config[:availability_zone],
37
+ })
38
+
39
+ @logger.debug("SSH public key successfully sent to instance")
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,85 @@
1
+ #
2
+ # Author:: GitHub Copilot
3
+ #
4
+ # Copyright:: 2025, GitHub
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # https://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ require "aws-sdk-ssm"
19
+ require "open3"
20
+
21
+ module Kitchen
22
+ module Driver
23
+ class Aws
24
+ # Manages AWS Systems Manager Session Manager connections for Test Kitchen
25
+ class SsmSessionManager
26
+ def initialize(config, logger)
27
+ @config = config
28
+ @logger = logger
29
+ @ssm_client = ::Aws::SSM::Client.new(
30
+ region: config[:region],
31
+ profile: config[:shared_credentials_profile]
32
+ )
33
+ end
34
+
35
+ # Check if SSM agent is running on the instance
36
+ def ssm_agent_available?(instance_id)
37
+ @logger.debug("Checking if SSM agent is available on instance #{instance_id}")
38
+
39
+ begin
40
+ resp = @ssm_client.describe_instance_information(
41
+ filters: [
42
+ {
43
+ key: "InstanceIds",
44
+ values: [instance_id],
45
+ },
46
+ ]
47
+ )
48
+
49
+ available = !resp.instance_information_list.empty? &&
50
+ resp.instance_information_list.first.ping_status == "Online"
51
+
52
+ if available
53
+ @logger.info("SSM agent is available on instance #{instance_id}")
54
+ else
55
+ @logger.warn("SSM agent is not available on instance #{instance_id}")
56
+ end
57
+
58
+ available
59
+ rescue ::Aws::SSM::Errors::ServiceError => e
60
+ @logger.warn("Error checking SSM agent status: #{e.message}")
61
+ false
62
+ end
63
+ end
64
+
65
+ # Verify that the AWS CLI session manager plugin is installed
66
+ def session_manager_plugin_installed?
67
+ _output, status = Open3.capture2e("session-manager-plugin", "--version")
68
+ installed = status.success?
69
+
70
+ if installed
71
+ @logger.debug("Session Manager plugin is installed")
72
+ else
73
+ @logger.warn("Session Manager plugin is not installed. Install it from: " \
74
+ "https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html")
75
+ end
76
+
77
+ installed
78
+ rescue StandardError => e
79
+ @logger.warn("Error checking for session-manager-plugin: #{e.message}")
80
+ false
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -23,17 +23,15 @@ module Kitchen
23
23
  class Debian < StandardPlatform
24
24
  StandardPlatform.platforms["debian"] = self
25
25
 
26
- # 12/13 are listed last since we default to the first item in the hash
27
- # and 12/13 are not released yet. When they're released move them up
28
26
  DEBIAN_CODENAMES = {
27
+ 13 => "trixie",
28
+ 12 => "bookworm",
29
29
  11 => "bullseye",
30
30
  10 => "buster",
31
31
  9 => "stretch",
32
32
  8 => "jessie",
33
33
  7 => "wheezy",
34
34
  6 => "squeeze",
35
- 12 => "bookworm",
36
- 13 => "trixie",
37
35
  }.freeze
38
36
 
39
37
  # default username for this platform's ami
@@ -16,6 +16,7 @@
16
16
  # See the License for the specific language governing permissions and
17
17
  # limitations under the License.
18
18
 
19
+ require "sshkey" unless defined?(SSHKey)
19
20
  require "benchmark" unless defined?(Benchmark)
20
21
  require "json" unless defined?(JSON)
21
22
  require "kitchen"
@@ -37,12 +38,15 @@ require_relative "aws/standard_platform/freebsd"
37
38
  require_relative "aws/standard_platform/macos"
38
39
  require_relative "aws/standard_platform/ubuntu"
39
40
  require_relative "aws/standard_platform/windows"
41
+ require_relative "aws/instance_connect"
42
+ require_relative "aws/ssm_session_manager"
40
43
  require "aws-sdk-ec2"
41
44
  require "aws-sdk-core/waiters/errors"
42
45
  require "retryable" unless defined?(Retryable)
43
46
  require "time" unless defined?(Time)
44
47
  require "etc" unless defined?(Etc)
45
48
  require "socket" unless defined?(Socket)
49
+ require "shellwords" unless defined?(Shellwords)
46
50
 
47
51
  module Kitchen
48
52
  module Driver
@@ -95,6 +99,11 @@ module Kitchen
95
99
  default_config :skip_cost_warning, false
96
100
  default_config :allocate_dedicated_host, false
97
101
  default_config :deallocate_dedicated_host, false
102
+ default_config :use_instance_connect, false
103
+ default_config :instance_connect_endpoint_id, nil
104
+ default_config :instance_connect_max_tunnel_duration, 3600
105
+ default_config :use_ssm_session_manager, false
106
+ default_config :ssm_session_manager_document_name, nil
98
107
 
99
108
  include Kitchen::Driver::Mixins::DedicatedHosts
100
109
 
@@ -191,6 +200,15 @@ module Kitchen
191
200
  end
192
201
  end
193
202
 
203
+ # Ensure use_instance_connect and use_ssm_session_manager are not both enabled
204
+ validations[:use_ssm_session_manager] = lambda do |_attr, val, driver|
205
+ if val && driver[:use_instance_connect]
206
+ warn "Cannot use both 'use_instance_connect' and 'use_ssm_session_manager' at the same time. " \
207
+ "Please enable only one transport method."
208
+ exit!
209
+ end
210
+ end
211
+
194
212
  # empty keys cause failures when tagging and they make no sense
195
213
  validations[:tags] = lambda do |_attr, val, _driver|
196
214
  # if someone puts the tags each on their own line it's an array not a hash
@@ -226,7 +244,8 @@ module Kitchen
226
244
  case config[:aws_ssh_key_id]
227
245
  when nil
228
246
  create_key(state)
229
- config[:aws_ssh_key_id] = state[:auto_key_id]
247
+ # Don't set aws_ssh_key_id if using Instance Connect
248
+ config[:aws_ssh_key_id] = state[:auto_key_id] unless config[:use_instance_connect]
230
249
  when "_disable"
231
250
  info("Disabling AWS-managed SSH key pairs for this EC2 instance.")
232
251
  info("The key pairs for the kitchen transport config and the AMI must match.")
@@ -265,13 +284,20 @@ module Kitchen
265
284
  # Waiting can fail, so we have to retry on that.
266
285
  Retryable.retryable(
267
286
  tries: 10,
268
- sleep: lambda { |n| [2**n, 30].min },
287
+ sleep: ->(n) { [2**n, 30].min },
269
288
  on: ::Aws::EC2::Errors::InvalidInstanceIDNotFound
270
289
  ) do |_r, _|
271
290
  wait_until_ready(server, state)
272
291
  end
273
292
 
274
293
  info("EC2 instance <#{state[:server_id]}> ready (hostname: #{state[:hostname]}).")
294
+
295
+ if config[:use_instance_connect]
296
+ instance_connect_setup_ready(state)
297
+ elsif config[:use_ssm_session_manager]
298
+ ssm_session_manager_setup_ready(state)
299
+ end
300
+
275
301
  instance.transport.connection(state).wait_until_ready
276
302
  attach_network_interface(state) unless config[:elastic_network_interface_id].nil?
277
303
  create_ec2_json(state) if /chef/i.match?(instance.provisioner.name)
@@ -444,10 +470,7 @@ module Kitchen
444
470
  expanded = []
445
471
  keys = %i{instance_type}
446
472
 
447
- unless config[:subnet_filter]
448
- # => Use explicitly specified subnets
449
- keys << :subnet_id
450
- else
473
+ if config[:subnet_filter]
451
474
  # => Enable cascading through matching subnets
452
475
  client = ::Aws::EC2::Client.new(region: config[:region])
453
476
 
@@ -472,6 +495,9 @@ module Kitchen
472
495
  new_config[:subnet_filter] = nil
473
496
  new_config
474
497
  end
498
+ else
499
+ # => Use explicitly specified subnets
500
+ keys << :subnet_id
475
501
  end
476
502
 
477
503
  keys.each do |key|
@@ -526,7 +552,7 @@ module Kitchen
526
552
  # not retry if the price could not be satisfied immediately.
527
553
  Retryable.retryable(
528
554
  tries: config[:spot_wait] / config[:retryable_sleep],
529
- sleep: lambda { |_n| config[:retryable_sleep] },
555
+ sleep: ->(_n) { config[:retryable_sleep] },
530
556
  on: ::Aws::EC2::Errors::SpotMaxPriceTooLow
531
557
  ) do |retries|
532
558
  c = retries * config[:retryable_sleep]
@@ -560,12 +586,12 @@ module Kitchen
560
586
  # supplied, try to fetch it from the AWS instance
561
587
  fetch_windows_admin_password(server, state)
562
588
  else
563
- output = server.console_output.output
589
+ output = server.console_output.output || ""
564
590
  unless output.nil?
565
591
  output = Base64.decode64(output)
566
592
  debug "Console output: --- \n#{output}"
567
593
  end
568
- ready = !!(output.include?("Windows is Ready to use"))
594
+ ready = !!output.include?("Windows is Ready to use")
569
595
  end
570
596
  end
571
597
  ready
@@ -671,7 +697,7 @@ module Kitchen
671
697
 
672
698
  def create_ec2_json(state)
673
699
  if windows_os?
674
- cmd = "New-Item -Force C:\\chef\\ohai\\hints\\ec2.json -ItemType File"
700
+ cmd = 'New-Item -Force C:\\chef\\ohai\\hints\\ec2.json -ItemType File'
675
701
  else
676
702
  debug "Using sudo_command='#{sudo_command}' for ohai hints"
677
703
  cmd = "#{sudo_command} mkdir -p /etc/chef/ohai/hints; #{sudo_command} touch /etc/chef/ohai/hints/ec2.json"
@@ -899,16 +925,16 @@ module Kitchen
899
925
  client = ::Aws::EC2::Client.new(region: config[:region])
900
926
  begin
901
927
  check_eni = client.describe_network_interface_attribute({
902
- attribute: "attachment",
903
- network_interface_id: config[:elastic_network_interface_id],
904
- })
928
+ attribute: "attachment",
929
+ network_interface_id: config[:elastic_network_interface_id],
930
+ })
905
931
  if check_eni.attachment.nil?
906
932
  unless state[:server_id].nil?
907
933
  client.attach_network_interface({
908
- device_index: 1,
909
- instance_id: state[:server_id],
910
- network_interface_id: config[:elastic_network_interface_id],
911
- })
934
+ device_index: 1,
935
+ instance_id: state[:server_id],
936
+ network_interface_id: config[:elastic_network_interface_id],
937
+ })
912
938
  info("Attached Network interface <#{config[:elastic_network_interface_id]}> with the instance <#{state[:server_id]}> .")
913
939
  end
914
940
  else
@@ -945,6 +971,456 @@ module Kitchen
945
971
  state.delete(:auto_key_id)
946
972
  File.unlink("#{config[:kitchen_root]}/.kitchen/#{instance.name}.pem")
947
973
  end
974
+
975
+ def finalize_config!(instance)
976
+ super
977
+
978
+ # Set up Instance Connect transport override if configured
979
+ if config[:use_instance_connect]
980
+ debug("[AWS EC2 Instance Connect] Setting up Instance Connect overrides")
981
+ instance_connect_setup_override(instance)
982
+ instance_connect_setup_inspec_override(instance)
983
+ elsif config[:use_ssm_session_manager]
984
+ debug("[AWS SSM Session Manager] Setting up SSM Session Manager overrides")
985
+ ssm_session_manager_setup_override(instance)
986
+ ssm_session_manager_setup_inspec_override(instance)
987
+ end
988
+
989
+ self
990
+ end
991
+
992
+ private
993
+
994
+ def instance_connect_setup_override(instance)
995
+ # Prevent double pushing of the SSH public keys
996
+ return if instance.transport.respond_to?(:instance_connect_override_applied)
997
+
998
+ # Store reference to driver for use in override
999
+ driver_instance = self
1000
+ use_instance_connect = config[:use_instance_connect]
1001
+
1002
+ # Override the transport's connection method to inject Instance Connect setup
1003
+ original_connection = instance.transport.method(:connection)
1004
+
1005
+ instance.transport.define_singleton_method(:connection) do |state, &block|
1006
+ # Set up Instance Connect configuration before every connection
1007
+ if use_instance_connect
1008
+ # Refresh Instance Connect SSH key
1009
+ driver_instance.send(:instance_connect_refresh_key, state)
1010
+
1011
+ # Configure connection mode based on endpoint availability
1012
+ if driver_instance.send(:instance_connect_endpoint_available?, state)
1013
+ # Proxy command mode - ensure ssh_proxy_command is set
1014
+ unless state[:ssh_proxy_command]
1015
+ driver_instance.send(:instance_connect_configure_ssh_proxy_command, state)
1016
+ end
1017
+ driver_instance.debug("[AWS EC2 Instance Connect] Transport using proxy command mode")
1018
+ else
1019
+ # Direct SSH mode - ensure hostname is set to public DNS
1020
+ driver_instance.send(:instance_connect_configure_direct_ssh, state)
1021
+ driver_instance.debug("[AWS EC2 Instance Connect] Transport using direct SSH mode")
1022
+ end
1023
+ end
1024
+ # Call original connection method
1025
+ original_connection.call(state, &block)
1026
+ end
1027
+
1028
+ # Mark as applied to prevent double pushing of the SSH public keys
1029
+ instance.transport.define_singleton_method(:instance_connect_override_applied) { true }
1030
+ end
1031
+
1032
+ def instance_connect_setup_inspec_override(instance)
1033
+ # Only apply to InSpec verifier
1034
+ return unless instance.verifier.name.downcase == "inspec"
1035
+ return if instance.verifier.respond_to?(:instance_connect_inspec_override_applied)
1036
+
1037
+ # Store reference to driver for use in override
1038
+ driver_instance = self
1039
+ use_instance_connect = config[:use_instance_connect]
1040
+
1041
+ # Override the verifier's call method to inject proxy command setup
1042
+ original_call = instance.verifier.method(:call)
1043
+
1044
+ instance.verifier.define_singleton_method(:call) do |state|
1045
+ driver_instance.debug("[AWS EC2 Instance Connect] InSpec call method intercepted, connecting using kitchen-ec2 driver AWS EC2 Instance Connect")
1046
+ driver_instance.debug("[AWS EC2 Instance Connect] Instance ID: #{state[:server_id]}")
1047
+
1048
+ # If using Instance Connect, set up the override just before the call
1049
+ if use_instance_connect && state[:server_id]
1050
+
1051
+ # Check if we already have the override method defined
1052
+ unless respond_to?(:instance_connect_original_runner_options_for_ssh)
1053
+ # Store the original method
1054
+ define_singleton_method(:instance_connect_original_runner_options_for_ssh, method(:runner_options_for_ssh))
1055
+
1056
+ # Override runner_options_for_ssh
1057
+ define_singleton_method(:runner_options_for_ssh) do |config_data|
1058
+ # Get the original options
1059
+ opts = instance_connect_original_runner_options_for_ssh(config_data)
1060
+
1061
+ # Inject Instance Connect configuration if enabled
1062
+ if use_instance_connect && config_data[:server_id]
1063
+ # Refresh Instance Connect SSH key
1064
+ driver_instance.send(:instance_connect_refresh_key, config_data)
1065
+
1066
+ # Check if we should use proxy command or direct SSH
1067
+ if driver_instance.send(:instance_connect_endpoint_available?, config_data)
1068
+ # Build proxy command with the instance ID from state
1069
+ proxy_command = [
1070
+ "aws", "ec2-instance-connect", "open-tunnel",
1071
+ "--instance-id", config_data[:server_id]
1072
+ ]
1073
+
1074
+ # Add optional parameters
1075
+ if driver_instance.config[:instance_connect_endpoint_id]
1076
+ proxy_command += ["--instance-connect-endpoint-id", driver_instance.config[:instance_connect_endpoint_id]]
1077
+ end
1078
+ if driver_instance.config[:instance_connect_max_tunnel_duration]
1079
+ proxy_command += ["--max-tunnel-duration", driver_instance.config[:instance_connect_max_tunnel_duration].to_s]
1080
+ end
1081
+ if driver_instance.config[:shared_credentials_profile]
1082
+ proxy_command += ["--profile", driver_instance.config[:shared_credentials_profile]]
1083
+ end
1084
+ proxy_command += ["--region", driver_instance.config[:region]]
1085
+
1086
+ opts["proxy_command"] = proxy_command.join(" ")
1087
+ driver_instance.info("[AWS EC2 Instance Connect] InSpec using proxy command: #{opts["proxy_command"]}")
1088
+ else
1089
+ # Direct SSH mode - ensure we're using the public DNS and proper SSH options
1090
+ server = driver_instance.ec2.get_instance(config_data[:server_id])
1091
+ public_dns = server&.public_dns_name
1092
+
1093
+ if public_dns && !public_dns.empty?
1094
+ opts["host"] = public_dns
1095
+ opts["ssh_options"] = (opts["ssh_options"] || {}).merge({
1096
+ "IdentitiesOnly" => "yes",
1097
+ })
1098
+ driver_instance.info("[AWS EC2 Instance Connect] InSpec using direct SSH to #{public_dns} with IdentitiesOnly=yes")
1099
+ else
1100
+ driver_instance.warn("[AWS EC2 Instance Connect] No public DNS available for direct SSH mode")
1101
+ end
1102
+ end
1103
+ else
1104
+ driver_instance.info("[AWS EC2 Instance Connect] Not configuring Instance Connect - use_instance_connect: #{use_instance_connect}, server_id present: #{!!config_data[:server_id]}")
1105
+ end
1106
+
1107
+ opts
1108
+ end
1109
+ end
1110
+ end
1111
+
1112
+ # Call the original method
1113
+ original_call.call(state)
1114
+ end
1115
+
1116
+ # Mark as applied to prevent double setup
1117
+ instance.verifier.define_singleton_method(:instance_connect_inspec_override_applied) { true }
1118
+ end
1119
+
1120
+ def instance_connect_setup_ready(state)
1121
+ # Determine whether to use proxy command or direct SSH based on endpoint availability
1122
+ if instance_connect_endpoint_available?(state)
1123
+ # Configure SSH proxy command if not already done
1124
+ instance_connect_configure_ssh_proxy_command(state) unless state[:ssh_proxy_command]
1125
+ info("[AWS EC2 Instance Connect] Using tunnel mode - Instance Connect endpoint available")
1126
+ else
1127
+ # Configure direct SSH with public DNS
1128
+ instance_connect_configure_direct_ssh(state)
1129
+ info("[AWS EC2 Instance Connect] Using direct SSH mode - no Instance Connect endpoint")
1130
+ end
1131
+
1132
+ # Refresh Instance Connect SSH key before connection
1133
+ instance_connect_refresh_key(state)
1134
+ end
1135
+
1136
+ def instance_connect_refresh_key(state)
1137
+ # Extract public key from the key that was already set up
1138
+ key_path = state[:ssh_key] || instance.transport[:ssh_key]
1139
+ return unless key_path
1140
+
1141
+ public_key = instance_connect_extract_public_key(key_path)
1142
+ return unless public_key
1143
+
1144
+ username = state[:username] || actual_platform&.username
1145
+
1146
+ # Build AWS CLI command to send public key
1147
+ cmd = [
1148
+ "aws", "ec2-instance-connect", "send-ssh-public-key",
1149
+ "--instance-id", state[:server_id],
1150
+ "--instance-os-user", username,
1151
+ "--ssh-public-key", public_key,
1152
+ "--region", config[:region]
1153
+ ]
1154
+
1155
+ cmd += ["--profile", config[:shared_credentials_profile]] if config[:shared_credentials_profile]
1156
+
1157
+ # Execute the command with proper shell escaping
1158
+ debug("[AWS EC2 Instance Connect] Refreshing SSH public key for #{state[:server_id]}")
1159
+ escaped_cmd = cmd.map { |arg| Shellwords.escape(arg) }.join(" ")
1160
+ result = `#{escaped_cmd} 2>&1`
1161
+ unless $?.success?
1162
+ warn("[AWS EC2 Instance Connect] Failed to refresh SSH key: #{result}")
1163
+ end
1164
+ end
1165
+
1166
+ def instance_connect_configure_ssh_proxy_command(state)
1167
+ info("[AWS EC2 Instance Connect] Configuring proxy command mode (tunnel)")
1168
+
1169
+ # Build the AWS CLI command for the tunnel
1170
+ proxy_command = [
1171
+ "aws", "ec2-instance-connect", "open-tunnel",
1172
+ "--instance-id", state[:server_id]
1173
+ ]
1174
+
1175
+ # Add optional parameters
1176
+ if config[:instance_connect_endpoint_id]
1177
+ proxy_command += ["--instance-connect-endpoint-id", config[:instance_connect_endpoint_id]]
1178
+ end
1179
+ if config[:instance_connect_max_tunnel_duration]
1180
+ proxy_command += ["--max-tunnel-duration", config[:instance_connect_max_tunnel_duration].to_s]
1181
+ end
1182
+ if config[:shared_credentials_profile]
1183
+ proxy_command += ["--profile", config[:shared_credentials_profile]]
1184
+ end
1185
+ proxy_command += ["--region", config[:region]]
1186
+ proxy_command_str = proxy_command.join(" ")
1187
+
1188
+ info("Configuring SSH to use Instance Connect tunnel: #{proxy_command_str}")
1189
+ state[:ssh_proxy_command] = proxy_command_str
1190
+
1191
+ # Store Instance Connect details for the transport to use
1192
+ state[:instance_connect_config] = {
1193
+ server_id: state[:server_id],
1194
+ username: state[:username] || actual_platform&.username,
1195
+ region: config[:region],
1196
+ profile: config[:shared_credentials_profile],
1197
+ tunnel_mode: true,
1198
+ }
1199
+ end
1200
+
1201
+ def instance_connect_endpoint_available?(state)
1202
+ # If explicitly configured, respect that configuration
1203
+ return true if config[:instance_connect_endpoint_id]
1204
+
1205
+ # Check if there are any instance connect endpoints in the VPC
1206
+ vpc_id = get_vpc_id_for_instance(state)
1207
+ return false unless vpc_id
1208
+
1209
+ begin
1210
+ endpoints = ec2.client.describe_instance_connect_endpoints(
1211
+ filters: [
1212
+ { name: "vpc-id", values: [vpc_id] },
1213
+ { name: "state", values: ["create-complete"] },
1214
+ ]
1215
+ ).instance_connect_endpoints
1216
+
1217
+ !endpoints.empty?
1218
+ rescue ::Aws::EC2::Errors::InvalidAction, ::Aws::EC2::Errors::UnauthorizedOperation => e
1219
+ # Instance Connect endpoints may not be available in this region or account
1220
+ debug("[AWS EC2 Instance Connect] Cannot check for endpoints: #{e.message}")
1221
+ false
1222
+ end
1223
+ end
1224
+
1225
+ def get_vpc_id_for_instance(state)
1226
+ # Get the instance details to find its VPC
1227
+ return unless state[:server_id]
1228
+
1229
+ begin
1230
+ instance_info = ec2.client.describe_instances(instance_ids: [state[:server_id]]).reservations.first&.instances&.first
1231
+ return unless instance_info
1232
+
1233
+ instance_info.vpc_id
1234
+ rescue => e
1235
+ debug("[AWS EC2 Instance Connect] Error getting VPC ID for instance: #{e.message}")
1236
+ nil
1237
+ end
1238
+ end
1239
+
1240
+ def instance_connect_configure_direct_ssh(state)
1241
+ # For direct SSH, we need to ensure the hostname is the public DNS name
1242
+ # and configure SSH options appropriately
1243
+ server = ec2.get_instance(state[:server_id])
1244
+ public_dns = server.public_dns_name
1245
+
1246
+ if public_dns && !public_dns.empty?
1247
+ info("[AWS EC2 Instance Connect] Configuring direct SSH to #{public_dns}")
1248
+ state[:hostname] = public_dns
1249
+
1250
+ # Store Instance Connect details for direct SSH mode
1251
+ state[:instance_connect_config] = {
1252
+ server_id: state[:server_id],
1253
+ username: state[:username] || actual_platform&.username,
1254
+ region: config[:region],
1255
+ profile: config[:shared_credentials_profile],
1256
+ direct_ssh: true,
1257
+ hostname: public_dns,
1258
+ }
1259
+ else
1260
+ warn("[AWS EC2 Instance Connect] No public DNS available for direct SSH, falling back to existing hostname")
1261
+ end
1262
+ end
1263
+
1264
+ def instance_connect_extract_public_key(private_key_path)
1265
+ public_key_path = "#{private_key_path}.pub"
1266
+
1267
+ if File.exist?(public_key_path)
1268
+ return File.read(public_key_path).strip
1269
+ end
1270
+
1271
+ begin
1272
+ key = SSHKey.new(File.read(private_key_path))
1273
+ key.ssh_public_key
1274
+ rescue => e
1275
+ raise "Unable to extract public key from #{private_key_path}: #{e.message}"
1276
+ end
1277
+ end
1278
+
1279
+ # SSM Session Manager Support Methods
1280
+
1281
+ def ssm_session_manager
1282
+ @ssm_session_manager ||= Aws::SsmSessionManager.new(config, instance.logger)
1283
+ end
1284
+
1285
+ def ssm_session_manager_setup_ready(state)
1286
+ info("[AWS SSM Session Manager] Setting up SSM Session Manager connection")
1287
+
1288
+ # Verify session manager plugin is installed
1289
+ unless ssm_session_manager.session_manager_plugin_installed?
1290
+ warn("[AWS SSM Session Manager] Session Manager plugin not found. Please install it from: " \
1291
+ "https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html")
1292
+ end
1293
+
1294
+ # Wait for SSM agent to be available
1295
+ max_retries = 12
1296
+ retry_delay = 10
1297
+ retries = 0
1298
+
1299
+ loop do
1300
+ if ssm_session_manager.ssm_agent_available?(state[:server_id])
1301
+ info("[AWS SSM Session Manager] SSM agent is available on instance #{state[:server_id]}")
1302
+ break
1303
+ end
1304
+
1305
+ retries += 1
1306
+ if retries >= max_retries
1307
+ warn("[AWS SSM Session Manager] SSM agent did not become available after #{max_retries * retry_delay} seconds. " \
1308
+ "Ensure the instance has an IAM instance profile with SSM permissions and the SSM agent is running.")
1309
+ break
1310
+ end
1311
+
1312
+ info("[AWS SSM Session Manager] Waiting for SSM agent to be available (attempt #{retries}/#{max_retries})...")
1313
+ sleep retry_delay
1314
+ end
1315
+ end
1316
+
1317
+ def ssm_session_manager_setup_override(instance)
1318
+ # Prevent double setup
1319
+ return if instance.transport.respond_to?(:ssm_session_manager_override_applied)
1320
+
1321
+ # Store reference to driver for use in override
1322
+ driver_instance = self
1323
+ use_ssm = config[:use_ssm_session_manager]
1324
+
1325
+ # Override the transport's connection method to inject SSM setup
1326
+ original_connection = instance.transport.method(:connection)
1327
+
1328
+ instance.transport.define_singleton_method(:connection) do |state, &block|
1329
+ if use_ssm && state[:server_id]
1330
+ # Build SSM start-session command
1331
+ cmd = [
1332
+ "aws", "ssm", "start-session",
1333
+ "--target", state[:server_id],
1334
+ "--region", driver_instance.config[:region]
1335
+ ]
1336
+
1337
+ # Add document name if specified
1338
+ if driver_instance.config[:ssm_session_manager_document_name]
1339
+ cmd += ["--document-name", driver_instance.config[:ssm_session_manager_document_name]]
1340
+ end
1341
+
1342
+ # Add AWS profile if specified
1343
+ if driver_instance.config[:shared_credentials_profile]
1344
+ cmd += ["--profile", driver_instance.config[:shared_credentials_profile]]
1345
+ end
1346
+
1347
+ proxy_command = cmd.join(" ")
1348
+ driver_instance.info("[AWS SSM Session Manager] Using proxy command: #{proxy_command}")
1349
+
1350
+ # Set proxy command for SSH transport
1351
+ state[:ssh_proxy_command] = proxy_command
1352
+ end
1353
+
1354
+ # Call original connection method
1355
+ original_connection.call(state, &block)
1356
+ end
1357
+
1358
+ # Mark as applied
1359
+ instance.transport.define_singleton_method(:ssm_session_manager_override_applied) { true }
1360
+ end
1361
+
1362
+ def ssm_session_manager_setup_inspec_override(instance)
1363
+ # Only apply to InSpec verifier
1364
+ return unless instance.verifier.name.downcase == "inspec"
1365
+ return if instance.verifier.respond_to?(:ssm_session_manager_inspec_override_applied)
1366
+
1367
+ # Store reference to driver for use in override
1368
+ driver_instance = self
1369
+ use_ssm = config[:use_ssm_session_manager]
1370
+
1371
+ # Override the verifier's call method to inject SSM setup
1372
+ original_call = instance.verifier.method(:call)
1373
+
1374
+ instance.verifier.define_singleton_method(:call) do |state|
1375
+ driver_instance.debug("[AWS SSM Session Manager] InSpec call method intercepted")
1376
+
1377
+ # Set up SSM for InSpec if enabled
1378
+ if use_ssm && state[:server_id]
1379
+ # Check if we already have the override method defined
1380
+ unless respond_to?(:ssm_original_runner_options_for_ssh)
1381
+ # Store the original method
1382
+ define_singleton_method(:ssm_original_runner_options_for_ssh, method(:runner_options_for_ssh))
1383
+
1384
+ # Override runner_options_for_ssh
1385
+ define_singleton_method(:runner_options_for_ssh) do |config_data|
1386
+ # Get the original options
1387
+ opts = ssm_original_runner_options_for_ssh(config_data)
1388
+
1389
+ # Inject SSM Session Manager configuration if enabled
1390
+ if use_ssm && config_data[:server_id]
1391
+ # Build SSM start-session command
1392
+ cmd = [
1393
+ "aws", "ssm", "start-session",
1394
+ "--target", config_data[:server_id],
1395
+ "--region", driver_instance.config[:region]
1396
+ ]
1397
+
1398
+ # Add document name if specified
1399
+ if driver_instance.config[:ssm_session_manager_document_name]
1400
+ cmd += ["--document-name", driver_instance.config[:ssm_session_manager_document_name]]
1401
+ end
1402
+
1403
+ # Add AWS profile if specified
1404
+ if driver_instance.config[:shared_credentials_profile]
1405
+ cmd += ["--profile", driver_instance.config[:shared_credentials_profile]]
1406
+ end
1407
+
1408
+ opts["proxy_command"] = cmd.join(" ")
1409
+ driver_instance.info("[AWS SSM Session Manager] InSpec using proxy command: #{opts["proxy_command"]}")
1410
+ end
1411
+
1412
+ opts
1413
+ end
1414
+ end
1415
+ end
1416
+
1417
+ # Call the original method
1418
+ original_call.call(state)
1419
+ end
1420
+
1421
+ # Mark as applied
1422
+ instance.verifier.define_singleton_method(:ssm_session_manager_inspec_override_applied) { true }
1423
+ end
948
1424
  end
949
1425
  end
950
1426
  end
@@ -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.20.0".freeze
22
+ EC2_VERSION = "3.22.0".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.20.0
4
+ version: 3.22.0
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: 2025-06-16 00:00:00.000000000 Z
11
+ date: 2026-01-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: aws-sdk-ec2
@@ -24,6 +24,34 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '1.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: aws-sdk-ec2instanceconnect
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: aws-sdk-ssm
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.0'
27
55
  - !ruby/object:Gem::Dependency
28
56
  name: retryable
29
57
  requirement: !ruby/object:Gem::Requirement
@@ -44,13 +72,27 @@ dependencies:
44
72
  - - "<"
45
73
  - !ruby/object:Gem::Version
46
74
  version: '4.0'
75
+ - !ruby/object:Gem::Dependency
76
+ name: sshkey
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '2.0'
82
+ type: :runtime
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '2.0'
47
89
  - !ruby/object:Gem::Dependency
48
90
  name: test-kitchen
49
91
  requirement: !ruby/object:Gem::Requirement
50
92
  requirements:
51
93
  - - ">="
52
94
  - !ruby/object:Gem::Version
53
- version: 1.4.1
95
+ version: 3.9.0
54
96
  - - "<"
55
97
  - !ruby/object:Gem::Version
56
98
  version: '4'
@@ -60,7 +102,7 @@ dependencies:
60
102
  requirements:
61
103
  - - ">="
62
104
  - !ruby/object:Gem::Version
63
- version: 1.4.1
105
+ version: 3.9.0
64
106
  - - "<"
65
107
  - !ruby/object:Gem::Version
66
108
  version: '4'
@@ -74,7 +116,9 @@ files:
74
116
  - LICENSE
75
117
  - lib/kitchen/driver/aws/client.rb
76
118
  - lib/kitchen/driver/aws/dedicated_hosts.rb
119
+ - lib/kitchen/driver/aws/instance_connect.rb
77
120
  - lib/kitchen/driver/aws/instance_generator.rb
121
+ - lib/kitchen/driver/aws/ssm_session_manager.rb
78
122
  - lib/kitchen/driver/aws/standard_platform.rb
79
123
  - lib/kitchen/driver/aws/standard_platform/alma.rb
80
124
  - lib/kitchen/driver/aws/standard_platform/amazon.rb