kitchen-google 3.0.1 → 3.1.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: 0b723b02e719da68e14ea28e557e7288ebf83e3f7770fe26b7a24c67b5edfa2a
4
- data.tar.gz: 7fc550f1fa2ee2ef1803849c04ed561eeb8417ea34c7a9dec37a336d02223d90
3
+ metadata.gz: 915d1d94065a49854c4c040df37b17d4a1e6853bbffd4872bb6e94d255e070bf
4
+ data.tar.gz: ab9bbf5503260572daf07e281d7087db57110b27ef25fc47f1879cfcc8a1e75d
5
5
  SHA512:
6
- metadata.gz: 619b786dc4939d75e666676eace51b0b87623ee800333e9569bd3ccc31204773badc2d5bcb449e2e9852faec5dc1cce997fd7d83dae58c07bb2651f265769081
7
- data.tar.gz: e260b90a3249fe4f5de2469e9777b0f6a6a3e8c21cb86989c8470ee575d17155b7505df325f792321f91d098549278cf368026ff6275dee369c56be26cfc904c
6
+ metadata.gz: ddba9a545b204a39596ed608a310e6714976bb1553af5ab18df6fb4bb135f323c18e5cba31097f7ecb603f1366bef4cfbd03c70b761904b26d16183db47d1b39
7
+ data.tar.gz: 66f9605d7a1c529939b3aa1fe0ea375bb1eac6a9689b5ea6cc6427d8f70bf8fdfed63f245f6daab76bca11ef228b52c2e62a989faf1c143ca8511e719f8b1b5b
@@ -21,6 +21,7 @@ require "kitchen"
21
21
  require_relative "gce_version"
22
22
  require_relative "gce/windows_password"
23
23
  require "securerandom" unless defined?(SecureRandom)
24
+ require "time" unless defined?(Time.iso8601)
24
25
  require "timeout" unless defined?(Timeout)
25
26
 
26
27
  module Kitchen
@@ -43,6 +44,12 @@ module Kitchen
43
44
  # @return [Hash] the Test Kitchen state hash for the action in progress
44
45
  attr_accessor :state
45
46
 
47
+ # @!method state=(value)
48
+ # Stores the Test Kitchen state hash for the action in progress.
49
+ #
50
+ # @param value [Hash] the state hash to expose to the helper methods
51
+ # @return [Hash] the assigned state hash
52
+
46
53
  # Maps the short scope aliases accepted by `gcloud` onto the scope
47
54
  # segment of their fully-qualified OAuth 2.0 URL.
48
55
  #
@@ -89,7 +96,10 @@ module Kitchen
89
96
  default_config :tags, []
90
97
  default_config :preemptible, false
91
98
  default_config :auto_restart, false
92
- default_config :auto_migrate, false
99
+ # Matches GCE's own default for onHostMaintenance. Several machine
100
+ # families, E2 among them, reject TERMINATE unless the instance is
101
+ # preemptible, so defaulting this off makes them unusable.
102
+ default_config :auto_migrate, true
93
103
  default_config :image_family, nil
94
104
  default_config :image_name, nil
95
105
  default_config :image_project, nil
@@ -102,10 +112,18 @@ module Kitchen
102
112
  default_config :metadata, {}
103
113
  default_config :labels, {}
104
114
 
115
+ # Pattern a GCE resource name must match in full. GCE applies the same
116
+ # pattern to instance names and disk names alike.
117
+ #
118
+ # @return [Regexp] the permitted name pattern
119
+ RESOURCE_NAME_REGEX = /(?:[a-z](?:[-a-z0-9]{0,61}[a-z0-9])?)/
120
+
105
121
  # Pattern a GCE disk name must match in full.
106
122
  #
123
+ # @deprecated Use {RESOURCE_NAME_REGEX}, which is the same pattern and
124
+ # says what it actually covers.
107
125
  # @return [Regexp] the permitted disk-name pattern
108
- DISK_NAME_REGEX = /(?:[a-z](?:[-a-z0-9]{0,61}[a-z0-9])?)/
126
+ DISK_NAME_REGEX = RESOURCE_NAME_REGEX
109
127
 
110
128
  # Longest instance name GCE accepts.
111
129
  #
@@ -138,6 +156,22 @@ module Kitchen
138
156
  disk_size: 10,
139
157
  }.freeze
140
158
 
159
+ # Local Windows account Test Kitchen's WinRM transport defaults to, and
160
+ # which Google's Windows images ship disabled.
161
+ #
162
+ # @return [String] the built-in administrator account name
163
+ BUILTIN_ADMINISTRATOR = "administrator".freeze
164
+
165
+ # Told to the user when they are about to wait out a WinRM timeout for a
166
+ # reason the driver can see coming.
167
+ #
168
+ # @return [String] the warning text
169
+ BUILTIN_ADMINISTRATOR_WARNING =
170
+ "The WinRM transport is connecting as the built-in Administrator account, which is " \
171
+ "disabled on Google's Windows images. The guest agent resets its password without " \
172
+ "enabling it, so the login will be refused. Set transport.username to any other name " \
173
+ "and the agent will create that account instead.".freeze
174
+
141
175
  # Human-readable driver name shown in Test Kitchen output.
142
176
  #
143
177
  # @return [String] the driver's display name
@@ -154,7 +188,8 @@ module Kitchen
154
188
  # before the error is re-raised.
155
189
  #
156
190
  # @param state [Hash] the Test Kitchen state hash, mutated in place with
157
- # `:server_name`, `:hostname` and `:zone`
191
+ # `:server_name`, `:hostname`, `:zone` and `:created_disks`, plus
192
+ # `:password` for Windows guests
158
193
  # @return [void]
159
194
  # @raise [StandardError] if instance creation fails for any reason
160
195
  def create(state)
@@ -163,19 +198,31 @@ module Kitchen
163
198
 
164
199
  validate!
165
200
 
201
+ # The disk configuration has to be settled first: the instance name is
202
+ # budgeted against the longest disk name derived from it.
203
+ create_disks_config
204
+
166
205
  server_name = generate_server_name
167
206
 
168
- create_disks_config
207
+ # Pin the zone before anything billable exists. In region mode it is
208
+ # picked at random, so cleanup would otherwise have no way to know
209
+ # where to look for the disks created below.
210
+ state[:zone] = zone
211
+
212
+ # Record the name before the request goes out, not after it returns.
213
+ # GCE starts billing as soon as the insert is accepted, and it may
214
+ # accept one whose response never reaches us -- an interrupted or
215
+ # timed-out request can leave an instance running that the state file
216
+ # knows nothing about, holding disks that cleanup then cannot delete.
217
+ # `destroy` copes with a name that never became an instance.
218
+ state[:server_name] = server_name
169
219
 
170
220
  info("Creating GCE instance <#{server_name}> in project #{project}, zone #{zone}...")
171
221
  operation = connection.insert_instance(project, zone, create_instance_object(server_name))
172
222
 
173
223
  wait_for_operation(operation)
174
224
 
175
- server = server_instance(server_name)
176
- state[:server_name] = server_name
177
- state[:hostname] = ip_address_for(server)
178
- state[:zone] = zone
225
+ state[:hostname] = ip_address_for(server_instance(server_name))
179
226
 
180
227
  info("Server <#{server_name}> created.")
181
228
 
@@ -197,33 +244,101 @@ module Kitchen
197
244
  raise
198
245
  end
199
246
 
200
- # Destroys the GCE instance recorded in the state file.
247
+ # Destroys the GCE instance recorded in the state file, together with any
248
+ # standalone disks an earlier create left behind.
201
249
  #
202
- # Does nothing when the state file records no server, or when the
203
- # instance no longer exists in GCE.
250
+ # An instance that no longer exists in GCE is treated as already
251
+ # destroyed, but the state file is still cleared: a create that fails
252
+ # after `insert_instance` records a server that may never have come into
253
+ # being, and leaving the name behind would make {#create}'s idempotency
254
+ # guard skip every subsequent retry.
255
+ #
256
+ # Disks are deleted after the instance, which holds them until it is
257
+ # gone. A create interrupted before it reached `insert_instance` records
258
+ # no server at all, so they are cleaned up whether or not there is one.
204
259
  #
205
260
  # @param state [Hash] the Test Kitchen state hash, mutated in place to
206
- # remove `:server_name`, `:hostname` and `:zone`
261
+ # remove `:server_name`, `:hostname`, `:zone` and `:created_disks`
207
262
  # @return [void]
208
263
  def destroy(state)
209
264
  @state = state
210
265
  server_name = state[:server_name]
211
- return if server_name.nil?
212
266
 
213
- unless server_exist?(server_name)
214
- info("GCE instance <#{server_name}> does not exist - assuming it has been already destroyed.")
215
- return
267
+ unless server_name.nil?
268
+ if server_exist?(server_name)
269
+ info("Destroying GCE instance <#{server_name}>...")
270
+ wait_for_operation(connection.delete_instance(project, zone, server_name))
271
+ info("GCE instance <#{server_name}> destroyed.")
272
+ else
273
+ info("GCE instance <#{server_name}> does not exist - assuming it has been already destroyed.")
274
+ end
216
275
  end
217
276
 
218
- info("Destroying GCE instance <#{server_name}>...")
219
- wait_for_operation(connection.delete_instance(project, zone, server_name))
220
- info("GCE instance <#{server_name}> destroyed.")
277
+ delete_created_disks
221
278
 
222
279
  state.delete(:server_name)
223
280
  state.delete(:hostname)
224
281
  state.delete(:zone)
225
282
  end
226
283
 
284
+ # Reports whether the instance recorded in the state file is actually
285
+ # running, by asking GCE rather than trusting the state file.
286
+ #
287
+ # Test Kitchen 4 calls this for `kitchen list --live` and the `kitchen
288
+ # status` alias. Earlier versions never call it, so implementing it is
289
+ # safe across the whole supported range.
290
+ #
291
+ # The state file records only what the last action did, which goes stale
292
+ # in both directions: a preemptible instance GCE has reclaimed still
293
+ # reads as "Created", and an instance deleted outside Test Kitchen leaves
294
+ # a server name behind that no longer resolves.
295
+ #
296
+ # @param state [Hash] the Test Kitchen state hash
297
+ # @return [Hash] the status, in the shape `Kitchen::Instance` expects
298
+ def status(state)
299
+ @state = state
300
+ server_name = state[:server_name]
301
+
302
+ return status_report(live: false, state_name: "not created") if server_name.nil?
303
+
304
+ gce_status = server_instance(server_name).status.to_s.downcase
305
+
306
+ status_report(
307
+ live: gce_status == "running",
308
+ state_name: gce_status,
309
+ resource_id: server_name
310
+ )
311
+ rescue Google::Apis::ClientError => e
312
+ debug("API error: #{e.message}")
313
+
314
+ status_report(
315
+ live: false,
316
+ state_name: "not found",
317
+ resource_id: server_name,
318
+ message: "Instance #{server_name} is recorded in the state file but no longer exists " \
319
+ "in project #{project}, zone #{zone}."
320
+ )
321
+ end
322
+
323
+ # Builds a status hash in the shape `Kitchen::Instance` expects.
324
+ #
325
+ # @param live [Boolean] whether the instance is up and usable
326
+ # @param state_name [String] the state to display
327
+ # @param resource_id [String, nil] the GCE instance name
328
+ # @param message [String, nil] anything the user should know
329
+ # @return [Hash] the status
330
+ # @api private
331
+ def status_report(live:, state_name:, resource_id: nil, message: nil)
332
+ {
333
+ live: live,
334
+ state: state_name,
335
+ source: "driver",
336
+ resource_id: resource_id,
337
+ message: message,
338
+ checked_at: Time.now.utc.iso8601,
339
+ }.compact
340
+ end
341
+
227
342
  # Whether the deprecated single-boot-disk options are configured.
228
343
  #
229
344
  # @return [Boolean] true if any of `autodelete_disk`, `disk_size` or
@@ -294,8 +409,9 @@ module Kitchen
294
409
  #
295
410
  # @param disks [Hash] the raw `disks` configuration, keyed by disk name
296
411
  # @return [Hash{Symbol => Hash}] the normalised disk configuration
297
- # @raise [RuntimeError] if a disk name or type is invalid, or more than
298
- # one boot disk is specified
412
+ # @raise [RuntimeError] if a disk name or type is invalid, if more than
413
+ # one boot disk is specified, or if {#assign_boot_disk} finds no disks
414
+ # at all and none eligible to boot
299
415
  # @api private
300
416
  def normalize_disks(disks)
301
417
  normalized = disks.each_with_object({}) do |(disk_name, disk_config), memo|
@@ -310,12 +426,15 @@ module Kitchen
310
426
  # Applies the disk defaults to one disk entry and validates the result.
311
427
  #
312
428
  # @param disk_name [String, Symbol] the disk's name, used in error messages
313
- # @param disk_config [Hash] the user-supplied configuration for this disk
429
+ # @param disk_config [Hash, nil] the user-supplied configuration for this
430
+ # disk. A bare `disk-name:` in `kitchen.yml` parses to nil rather than
431
+ # to an empty hash, and means the same thing: take the defaults.
314
432
  # @return [Hash] the disk configuration with defaults applied
315
433
  # @raise [RuntimeError] if the disk type is invalid, a local SSD is
316
434
  # marked bootable, or a size is given for a local SSD
317
435
  # @api private
318
436
  def normalize_disk(disk_name, disk_config)
437
+ disk_config ||= {}
319
438
  normalized = DISK_DEFAULT_CONFIG.merge(disk_config)
320
439
 
321
440
  unless valid_disk_type?(normalized[:disk_type])
@@ -382,7 +501,8 @@ module Kitchen
382
501
  #
383
502
  # @return [void]
384
503
  # @raise [RuntimeError] if any configured project, zone, region, machine
385
- # type, network, subnet, image or disk setting is invalid
504
+ # type, network, subnet, image or disk setting is invalid, or if the
505
+ # WinRM transport is in use without `email` set
386
506
  def validate!
387
507
  raise "Project #{config[:project]} is not a valid project" unless valid_project?
388
508
  raise "Either zone or region must be specified" unless config[:zone] || config[:region]
@@ -397,11 +517,13 @@ module Kitchen
397
517
  raise "You cannot use autodelete_disk, disk_size or disk_type with the new disks configuration" if old_disk_configuration_present? && new_disk_configuration_present?
398
518
  raise "Disk image #{config[:image_name]} is not valid - check your image name and image project" if boot_disk_source_image.nil?
399
519
 
520
+ warn(BUILTIN_ADMINISTRATOR_WARNING) if winrm_transport? && builtin_administrator?
400
521
  warn("Both zone and region specified - region will be ignored.") if config[:zone] && config[:region]
401
522
  warn("Both image family and name specified - image family will be ignored") if config[:image_family] && config[:image_name]
402
523
  warn("Image project not specified - searching current project only") unless config[:image_project]
403
524
  warn("Subnet project not specified - searching current project only") if config[:subnet] && !config[:subnet_project]
404
525
  warn("Auto-migrate disabled for preemptible instance") if preemptible? && config[:auto_migrate]
526
+ warn("Auto-migrate disabled for instance with guest accelerators") if guest_accelerators? && config[:auto_migrate]
405
527
  warn("Auto-restart disabled for preemptible instance") if preemptible? && config[:auto_restart]
406
528
  warn("These configs are deprecated - consider using new disks configuration") if old_disk_configuration_present?
407
529
  end
@@ -424,7 +546,8 @@ module Kitchen
424
546
 
425
547
  # Application default credentials scoped for Compute Engine.
426
548
  #
427
- # @return [Google::Auth::Credentials] the resolved credentials
549
+ # @return [Signet::OAuth2::Client] the resolved application default
550
+ # credentials
428
551
  def authorization
429
552
  @authorization ||= Google::Auth.get_application_default(
430
553
  [
@@ -434,6 +557,14 @@ module Kitchen
434
557
  )
435
558
  end
436
559
 
560
+ # Whether the WinRM transport is configured to log in as the built-in
561
+ # Administrator account.
562
+ #
563
+ # @return [Boolean] true if the transport username is `administrator`
564
+ def builtin_administrator?
565
+ instance.transport[:username].to_s.casecmp?(BUILTIN_ADMINISTRATOR)
566
+ end
567
+
437
568
  # Whether the suite's transport is WinRM, implying a Windows guest.
438
569
  #
439
570
  # @return [Boolean] true when the transport is WinRM
@@ -469,6 +600,7 @@ module Kitchen
469
600
  # Runs an API call and reports whether it succeeded, swallowing client
470
601
  # errors so callers can use it as a validity predicate.
471
602
  #
603
+ # @param block [Proc] the API call to attempt, called with no arguments
472
604
  # @yield the API call to attempt
473
605
  # @return [Boolean] true if the call succeeded, false on a client error
474
606
  def check_api_call(&block)
@@ -717,16 +849,97 @@ module Kitchen
717
849
  # Builds a unique, GCE-legal instance name, falling back to a UUID when
718
850
  # the Test Kitchen instance name would make it too long.
719
851
  #
852
+ # Uppercase is folded rather than discarded. GCE disallows it, but it
853
+ # still carries meaning, and replacing it with hyphens turned an
854
+ # `inst_name` of `MyTestVM` into `-y-est--` -- a name the user could not
855
+ # recognise as anything they had typed.
856
+ #
720
857
  # @return [String] the instance name
858
+ # @raise [RuntimeError] if no legal name can be salvaged
721
859
  def generate_server_name
722
- name = config[:inst_name] || "tk-#{instance.name.downcase}-#{SecureRandom.hex(3)}"
860
+ name = (config[:inst_name] || "tk-#{instance.name}-#{SecureRandom.hex(3)}").downcase
723
861
 
724
- if name.length > MAX_INSTANCE_NAME_LENGTH
862
+ if name.length > max_server_name_length
725
863
  warn("The TK instance name (#{instance.name}) has been removed from the GCE instance name due to size limitations. Consider setting shorter platform or suite names.")
726
- name = "tk-#{SecureRandom.uuid}"
864
+ name = fallback_server_name
727
865
  end
728
866
 
729
- name.gsub(/([^-a-z0-9])/, "-")
867
+ validated_server_name(name.gsub(/([^-a-z0-9])/, "-"))
868
+ end
869
+
870
+ # Checks a generated instance name against the pattern GCE enforces.
871
+ #
872
+ # Substitution cannot fix every name -- a leading underscore becomes a
873
+ # leading hyphen, which GCE still rejects -- so say so here rather than
874
+ # letting the API refuse a name the user never wrote.
875
+ #
876
+ # @param name [String] the generated name
877
+ # @return [String] the same name, once it is known to be legal
878
+ # @raise [RuntimeError] if the name does not match {RESOURCE_NAME_REGEX}
879
+ # @api private
880
+ def validated_server_name(name)
881
+ return name if name.match?(/\A#{RESOURCE_NAME_REGEX}\z/)
882
+
883
+ raise "Instance name #{name} is not valid. GCE names must match " \
884
+ "#{RESOURCE_NAME_REGEX.source} - they start with a lowercase letter, and end " \
885
+ "with a lowercase letter or a digit."
886
+ end
887
+
888
+ # Longest instance name that still leaves room for every disk named
889
+ # after it.
890
+ #
891
+ # GCE allows {MAX_INSTANCE_NAME_LENGTH} characters for an instance name
892
+ # and the same for a disk name, and {#create_disks} names each disk
893
+ # `"<instance>-<disk>"`. Spending the whole budget on the instance is
894
+ # what let a perfectly legal instance name produce an illegal disk name.
895
+ #
896
+ # @return [Integer] the maximum length of a generated instance name
897
+ # @raise [RuntimeError] if a configured disk name is long enough that
898
+ # fewer than {#fallback_server_name_length} characters remain
899
+ # @api private
900
+ def max_server_name_length
901
+ limit = MAX_INSTANCE_NAME_LENGTH - longest_disk_name_suffix
902
+
903
+ if limit < fallback_server_name_length
904
+ raise "Disk name #{longest_disk_name} is too long. GCE allows " \
905
+ "#{MAX_INSTANCE_NAME_LENGTH} characters for the disk name " \
906
+ "<instance name>-#{longest_disk_name}, which leaves no room for the instance name."
907
+ end
908
+
909
+ limit
910
+ end
911
+
912
+ # The longest disk name configured, which bounds the instance name.
913
+ #
914
+ # @return [String, nil] the longest disk name
915
+ # @api private
916
+ def longest_disk_name
917
+ Array(config[:disks]&.keys).map(&:to_s).max_by(&:length)
918
+ end
919
+
920
+ # Characters the longest disk name costs an instance name, including the
921
+ # hyphen joining the two.
922
+ #
923
+ # @return [Integer] the reserved length
924
+ # @api private
925
+ def longest_disk_name_suffix
926
+ longest_disk_name.nil? ? 0 : longest_disk_name.length + 1
927
+ end
928
+
929
+ # Name used when the Test Kitchen instance name will not fit.
930
+ #
931
+ # @return [String] a UUID-based instance name
932
+ # @api private
933
+ def fallback_server_name
934
+ "tk-#{SecureRandom.uuid}"
935
+ end
936
+
937
+ # Length of a {#fallback_server_name}, which is fixed.
938
+ #
939
+ # @return [Integer] the fallback name's length
940
+ # @api private
941
+ def fallback_server_name_length
942
+ @fallback_server_name_length ||= fallback_server_name.length
730
943
  end
731
944
 
732
945
  # Builds every disk for the instance, creating standalone persistent
@@ -771,18 +984,65 @@ module Kitchen
771
984
  info("Creating a #{LOCAL_SSD_SIZE_GB} GB local ssd as scratch disk (https://cloud.google.com/compute/docs/disks/#localssds).")
772
985
  disk.type = "SCRATCH"
773
986
  elsif disk.boot
774
- info("Creating a #{disk_config[:disk_size]} GB boot disk named #{unique_disk_name} from image #{image_name}...")
987
+ params.disk_size_gb = disk_size_for_image(disk_config[:disk_size], image_name)
988
+ info("Creating a #{params.disk_size_gb} GB boot disk named #{unique_disk_name} from image #{image_name}...")
775
989
  params.source_image = boot_disk_source_image
776
990
  params.disk_name = unique_disk_name
777
991
  else
778
- info("Creating a #{disk_config[:disk_size]} GB extra disk named #{unique_disk_name} from image #{disk_config[:custom_image]}...")
779
- params.source_image = image_url(disk_config[:custom_image])
992
+ custom_image = disk_config[:custom_image]
993
+ params.source_image = custom_image_url(custom_image)
994
+ params.disk_size_gb = disk_size_for_image(disk_config[:disk_size], custom_image)
780
995
  params.disk_name = unique_disk_name
996
+ info("Creating a #{params.disk_size_gb} GB extra disk named #{unique_disk_name} from image #{custom_image}...")
781
997
  end
782
998
  disk.initialize_params = params
783
999
  disk
784
1000
  end
785
1001
 
1002
+ # The size to request for a disk cloned from an image.
1003
+ #
1004
+ # GCE refuses to create a disk smaller than the image it is cloned from,
1005
+ # and the driver's own 10 GB default is smaller than many stock images -
1006
+ # every Windows image is 50 GB, and Rocky and CentOS are 20 GB. Rather
1007
+ # than fail the run over a size the user never chose, raise the request
1008
+ # to what the image needs and say so.
1009
+ #
1010
+ # @param requested [Integer, nil] the configured size in gigabytes
1011
+ # @param image [String, nil] the image the disk is cloned from
1012
+ # @return [Integer, nil] the size to request
1013
+ # @api private
1014
+ def disk_size_for_image(requested, image)
1015
+ image_size = image_disk_size_gb(image)
1016
+ return requested if image_size.nil? || (!requested.nil? && requested >= image_size)
1017
+
1018
+ warn("Requested disk size of #{requested} GB is smaller than image #{image} " \
1019
+ "(#{image_size} GB) - creating a #{image_size} GB disk instead.")
1020
+ image_size
1021
+ end
1022
+
1023
+ # The size, in gigabytes, of an image in the image project.
1024
+ #
1025
+ # Memoised per image name, since the boot image is looked up more than
1026
+ # once during a single action.
1027
+ #
1028
+ # @param image [String, nil] the image name
1029
+ # @return [Integer, nil] the image's size, or nil if it cannot be read
1030
+ # @api private
1031
+ def image_disk_size_gb(image)
1032
+ return if image.nil?
1033
+
1034
+ @image_disk_sizes ||= {}
1035
+ return @image_disk_sizes[image] if @image_disk_sizes.key?(image)
1036
+
1037
+ @image_disk_sizes[image] =
1038
+ begin
1039
+ connection.get_image(image_project, image).disk_size_gb
1040
+ rescue Google::Apis::ClientError => e
1041
+ debug("Unable to read the size of image #{image}: #{e.message}")
1042
+ nil
1043
+ end
1044
+ end
1045
+
786
1046
  # Creates a standalone persistent disk, waits for it to become ready, and
787
1047
  # returns a reference attaching it to the instance.
788
1048
  #
@@ -807,21 +1067,28 @@ module Kitchen
807
1067
  attached_disk
808
1068
  end
809
1069
 
810
- # Names of the standalone disks this driver created during the current
811
- # action, tracked so they can be cleaned up if creation fails.
1070
+ # Names of the standalone disks this driver has created, held in the Test
1071
+ # Kitchen state file rather than in memory.
1072
+ #
1073
+ # These disks bill from the moment GCE creates them, which is before the
1074
+ # instance exists, and the driver's own cleanup cannot be relied on to
1075
+ # remove them: `Interrupt` is not a `StandardError`, so pressing Ctrl-C
1076
+ # during a create skips the rescue entirely. Test Kitchen writes the
1077
+ # state file whatever happens, so recording the names there is what makes
1078
+ # a later `kitchen destroy` able to find them.
812
1079
  #
813
1080
  # @return [Array<String>] the created disk names
814
1081
  def created_disk_names
815
- @created_disk_names ||= []
1082
+ state[:created_disks] ||= []
816
1083
  end
817
1084
 
818
- # Deletes every standalone disk created during a failed create, so a
819
- # partial run does not leave billable disks behind.
1085
+ # Deletes every standalone disk this driver created, so a partial run
1086
+ # does not leave billable disks behind.
820
1087
  #
821
1088
  # @return [void]
822
1089
  def delete_created_disks
823
1090
  created_disk_names.each { |disk_name| delete_disk(disk_name) }
824
- created_disk_names.clear
1091
+ state.delete(:created_disks)
825
1092
  end
826
1093
 
827
1094
  # Deletes a standalone persistent disk, tolerating one that is already
@@ -873,13 +1140,44 @@ module Kitchen
873
1140
  "projects/#{image_project}/global/images/#{image}" if image_exist?(image)
874
1141
  end
875
1142
 
1143
+ # URL of a non-boot disk's `custom_image`, which must exist.
1144
+ #
1145
+ # GCE reads a missing `sourceImage` as a request for a blank disk, so an
1146
+ # image the driver cannot resolve would otherwise produce an empty disk
1147
+ # and a successful run -- a test that silently verifies nothing. The boot
1148
+ # disk has been guarded against exactly this in {#validate!} for years.
1149
+ #
1150
+ # The message names the project searched because that is where the
1151
+ # surprise usually is: `custom_image` is resolved in `image_project`, so
1152
+ # booting from a public image such as ubuntu-os-cloud sends the lookup
1153
+ # there rather than to the user's own project.
1154
+ #
1155
+ # @param image [String] the custom image name
1156
+ # @return [String] the image URL
1157
+ # @raise [RuntimeError] if the image does not exist in the image project
1158
+ # @api private
1159
+ def custom_image_url(image)
1160
+ image_url(image) ||
1161
+ raise("Disk image #{image} was not found in project #{image_project} - check the " \
1162
+ "custom_image name, and note that custom images are looked up in image_project.")
1163
+ end
1164
+
876
1165
  # Resolves the current image name for an image family.
877
1166
  #
1167
+ # A family the API cannot see is a configuration mistake like any other,
1168
+ # so report it the way {#validate!} reports an unresolvable `image_name`
1169
+ # rather than letting the Google client's own error escape.
1170
+ #
878
1171
  # @param image_family [String] the image family
879
1172
  # @return [String] the image name
1173
+ # @raise [RuntimeError] if the family does not exist in the image project
880
1174
  def image_name_for_family(image_family)
881
1175
  image = connection.get_image_from_family(image_project, image_family)
882
1176
  image.name
1177
+ rescue Google::Apis::ClientError => e
1178
+ debug("API error: #{e.message}")
1179
+ raise "Image family #{image_family} is not valid - it was not found in project " \
1180
+ "#{image_project}. Check the family name and image_project."
883
1181
  end
884
1182
 
885
1183
  # Partial URL identifying the machine type in the target zone.
@@ -921,10 +1219,11 @@ module Kitchen
921
1219
  guest_accelerator_configs
922
1220
  end
923
1221
 
924
- # The instance metadata, merging the driver's own keys over any the user
925
- # configured and adding a WinRM bootstrap script for Windows guests.
1222
+ # The instance metadata: the driver's own keys, overlaid with any the
1223
+ # user configured, plus a WinRM bootstrap script for Windows guests.
926
1224
  #
927
- # @return [Hash{String => String}] the metadata
1225
+ # @return [Hash{String => Object}] the metadata, string-keyed; user
1226
+ # values keep whatever type `kitchen.yml` gave them
928
1227
  def metadata
929
1228
  default_metadata = {
930
1229
  "created-by" => "test-kitchen",
@@ -939,7 +1238,28 @@ module Kitchen
939
1238
  end
940
1239
  end
941
1240
 
942
- config[:metadata].merge(default_metadata)
1241
+ default_metadata.merge(user_metadata(default_metadata.keys))
1242
+ end
1243
+
1244
+ # The user's own metadata, keyed the way the GCE API expects.
1245
+ #
1246
+ # Test Kitchen symbolises every key it loads from `kitchen.yml`, so a
1247
+ # user writing `created-by:` reaches the driver as `:"created-by"` and
1248
+ # never as the string the driver sets itself. Merging the two hashes
1249
+ # untouched therefore sends both spellings of the same key, and GCE
1250
+ # rejects the instance outright with "Metadata has duplicate keys".
1251
+ #
1252
+ # @param driver_keys [Array<String>] keys the driver sets itself, warned
1253
+ # about when the user replaces one
1254
+ # @return [Hash{String => Object}] the user's metadata, string-keyed
1255
+ # @api private
1256
+ def user_metadata(driver_keys)
1257
+ metadata = config[:metadata].transform_keys(&:to_s)
1258
+
1259
+ overridden = driver_keys & metadata.keys
1260
+ warn("Overriding metadata set by the driver: #{overridden.join(", ")}") unless overridden.empty?
1261
+
1262
+ metadata
943
1263
  end
944
1264
 
945
1265
  # The metadata in the form the GCE API expects.
@@ -1031,16 +1351,24 @@ module Kitchen
1031
1351
  config[:preemptible] ? true : false
1032
1352
  end
1033
1353
 
1034
- # Whether the instance may live-migrate. Always false when preemptible,
1035
- # which GCE does not allow to migrate.
1354
+ # Whether the instance may live-migrate. Always false for preemptible
1355
+ # instances and for instances with guest accelerators attached, neither
1356
+ # of which GCE will migrate.
1036
1357
  #
1037
1358
  # @return [Boolean] true if live migration is enabled
1038
1359
  def auto_migrate?
1039
- return false if preemptible?
1360
+ return false if preemptible? || guest_accelerators?
1040
1361
 
1041
1362
  config[:auto_migrate] ? true : false
1042
1363
  end
1043
1364
 
1365
+ # Whether any guest accelerators are attached.
1366
+ #
1367
+ # @return [Boolean] true if the instance has at least one accelerator
1368
+ def guest_accelerators?
1369
+ !Array(config[:guest_accelerators]).empty?
1370
+ end
1371
+
1044
1372
  # Whether the instance should restart automatically. Always false when
1045
1373
  # preemptible, which GCE does not allow to auto-restart.
1046
1374
  #
@@ -1117,6 +1445,7 @@ module Kitchen
1117
1445
  # logging each status change.
1118
1446
  #
1119
1447
  # @param requested_status [String] the status to wait for
1448
+ # @param block [Proc] called once per poll to re-fetch the resource
1120
1449
  # @yieldreturn [#status] the resource to poll
1121
1450
  # @return [void]
1122
1451
  # @raise [Timeout::Error] if the status is not reached within {#wait_time}
@@ -28,6 +28,6 @@ module Kitchen
28
28
  # release. Keep it on one line.
29
29
  #
30
30
  # @return [String] the gem version
31
- GCE_VERSION = "3.0.1"
31
+ GCE_VERSION = "3.1.1"
32
32
  end
33
33
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kitchen-google
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.1
4
+ version: 3.1.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: google-apis-compute_v1
@@ -100,6 +100,20 @@ dependencies:
100
100
  - - ">="
101
101
  - !ruby/object:Gem::Version
102
102
  version: '0.1'
103
+ - !ruby/object:Gem::Dependency
104
+ name: time
105
+ requirement: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0.1'
110
+ type: :runtime
111
+ prerelease: false
112
+ version_requirements: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0.1'
103
117
  - !ruby/object:Gem::Dependency
104
118
  name: timeout
105
119
  requirement: !ruby/object:Gem::Requirement