kitchen-google 2.7.0 → 2.8.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: d48612f9afc24d49211befb4cbd737f675183282dabb65e999448f65ba4ff7bb
4
- data.tar.gz: 805942449153b5d7ee8f5f1759d57fa6f148c7df665ad3f3a532bfd6d40b1917
3
+ metadata.gz: d96f1f6b4c605799e8b3af57a4e8966a786f808f1c3e5e2c201d34e306da454d
4
+ data.tar.gz: 2f8f62792d8c6b15a568dd5f8abdf177acafad3762df1e04ccacf4b69f502fe2
5
5
  SHA512:
6
- metadata.gz: 1183209c59c28b6d32e09a7b1bf2378e42c7e9f37fe93e5bc6a63f2a2ca172add9b276385428fa40c8db7c8d7ca505870deac9d4fc7396fbeef03b5fa7f0372b
7
- data.tar.gz: e2b9ec9c2d96e4c538ad70843fe2ec5c0e554f89c73da1d35e8bdc3d00680348d59feb88130af3e01e0b86b1236ae8d329f25af739bbd1b487f7adc1d50fd7b3
6
+ metadata.gz: cb7e05264bf801fc495576aca5ebd797193ea5e6140702e26d4248a0a3c54892702422fef3a563b2e78882f92dc2abedbae99b00371afcefdadeecc4bafa27bc
7
+ data.tar.gz: de05cdf3ae3b40aac2d40fb7fb3e3154dafb16d01979632a83ea7de5b4fdc249343c10d63407c0a36e09bd22ec945a829468a9d61f1caca58e52a2bfffa1fbfa
@@ -21,15 +21,32 @@ require "google/apis/compute_v1"
21
21
  require "kitchen"
22
22
  require_relative "gce_version"
23
23
  require "securerandom" unless defined?(SecureRandom)
24
+ require "timeout" unless defined?(Timeout)
24
25
 
25
26
  module Kitchen
26
27
  module Driver
27
- # Google Compute Engine driver for Test Kitchen
28
+ # Google Compute Engine driver for Test Kitchen.
29
+ #
30
+ # Creates and destroys GCE instances for Test Kitchen suites, translating
31
+ # `kitchen.yml` driver configuration into Google Compute Engine API calls.
28
32
  #
29
33
  # @author Andrew Leonard <andy@hurricane-ridge.com>
34
+ #
35
+ # @example Minimal kitchen.yml configuration
36
+ # driver:
37
+ # name: gce
38
+ # project: my-gcp-project
39
+ # zone: us-central1-a
40
+ # image_family: ubuntu-2204-lts
41
+ # image_project: ubuntu-os-cloud
30
42
  class Gce < Kitchen::Driver::Base
43
+ # @return [Hash] the Test Kitchen state hash for the action in progress
31
44
  attr_accessor :state
32
45
 
46
+ # Maps the short scope aliases accepted by `gcloud` onto the scope
47
+ # segment of their fully-qualified OAuth 2.0 URL.
48
+ #
49
+ # @return [Hash{String => String}] alias to scope-path mapping
33
50
  SCOPE_ALIAS_MAP = {
34
51
  "bigquery" => "bigquery",
35
52
  "cloud-platform" => "cloud-platform",
@@ -85,12 +102,55 @@ module Kitchen
85
102
  default_config :metadata, {}
86
103
  default_config :labels, {}
87
104
 
105
+ # Pattern a GCE disk name must match in full.
106
+ #
107
+ # @return [Regexp] the permitted disk-name pattern
88
108
  DISK_NAME_REGEX = /(?:[a-z](?:[-a-z0-9]{0,61}[a-z0-9])?)/
89
109
 
110
+ # Longest instance name GCE accepts.
111
+ #
112
+ # @return [Integer] the maximum instance-name length
113
+ MAX_INSTANCE_NAME_LENGTH = 63
114
+
115
+ # Fixed size, in gigabytes, of every GCE local SSD.
116
+ #
117
+ # @see https://cloud.google.com/compute/docs/disks/#localssds
118
+ # @return [Integer] the local SSD size
119
+ LOCAL_SSD_SIZE_GB = 375
120
+
121
+ # Disk type identifying a local SSD rather than a persistent disk.
122
+ #
123
+ # @return [String] the local SSD disk type
124
+ LOCAL_SSD_TYPE = "local-ssd".freeze
125
+
126
+ # Configuration applied to every disk before the user's own settings.
127
+ #
128
+ # @return [Hash] the per-disk defaults
129
+ DISK_DEFAULT_CONFIG = {
130
+ autodelete_disk: true,
131
+ disk_size: 10,
132
+ disk_type: "pd-standard",
133
+ }.freeze
134
+
135
+ # Human-readable driver name shown in Test Kitchen output.
136
+ #
137
+ # @return [String] the driver's display name
90
138
  def name
91
139
  "Google Compute (GCE)"
92
140
  end
93
141
 
142
+ # Creates a GCE instance for the Test Kitchen suite and waits until its
143
+ # transport is reachable.
144
+ #
145
+ # Returns immediately if the state file already records a server, making
146
+ # the action idempotent. If any step fails, the partially-created
147
+ # instance and any standalone disks created along the way are torn down
148
+ # before the error is re-raised.
149
+ #
150
+ # @param state [Hash] the Test Kitchen state hash, mutated in place with
151
+ # `:server_name`, `:hostname` and `:zone`
152
+ # @return [void]
153
+ # @raise [StandardError] if instance creation fails for any reason
94
154
  def create(state)
95
155
  @state = state
96
156
  return if state[:server_name]
@@ -121,10 +181,24 @@ module Kitchen
121
181
  info("GCE instance <#{server_name}> created and ready.")
122
182
  rescue => e
123
183
  error("Error encountered during server creation: #{e.class}: #{e.message}")
124
- destroy(state)
184
+ begin
185
+ # The instance must go first: its disks cannot be deleted while it
186
+ # still holds them.
187
+ destroy(state)
188
+ ensure
189
+ delete_created_disks
190
+ end
125
191
  raise
126
192
  end
127
193
 
194
+ # Destroys the GCE instance recorded in the state file.
195
+ #
196
+ # Does nothing when the state file records no server, or when the
197
+ # instance no longer exists in GCE.
198
+ #
199
+ # @param state [Hash] the Test Kitchen state hash, mutated in place to
200
+ # remove `:server_name`, `:hostname` and `:zone`
201
+ # @return [void]
128
202
  def destroy(state)
129
203
  @state = state
130
204
  server_name = state[:server_name]
@@ -144,78 +218,162 @@ module Kitchen
144
218
  state.delete(:zone)
145
219
  end
146
220
 
221
+ # Whether the deprecated single-boot-disk options are configured.
222
+ #
223
+ # @return [Boolean] true if any of `autodelete_disk`, `disk_size` or
224
+ # `disk_type` is set
147
225
  def old_disk_configuration_present?
148
226
  !config[:autodelete_disk].nil? || !config[:disk_size].nil? || !config[:disk_type].nil?
149
227
  end
150
228
 
229
+ # Whether the multi-disk `disks` option is configured.
230
+ #
231
+ # @return [Boolean] true if `disks` is set
151
232
  def new_disk_configuration_present?
152
233
  !config[:disks].nil?
153
234
  end
154
235
 
236
+ # Normalises whichever disk configuration style the user supplied into
237
+ # the canonical `disks` hash the rest of the driver consumes.
238
+ #
239
+ # Deprecated single-disk options are converted to a one-entry `disks`
240
+ # hash; an explicit `disks` hash has defaults applied, is validated, and
241
+ # has a boot disk chosen when none was flagged. When neither is present a
242
+ # single default boot disk is configured.
243
+ #
244
+ # @return [Hash{Symbol => Hash}] the normalised disk configuration, also
245
+ # written back to `config[:disks]`
246
+ # @raise [RuntimeError] if a disk name, disk type or boot-disk
247
+ # arrangement is invalid
155
248
  def create_disks_config
156
- # This can't be present in default_config because we couldn't
157
- # determine which disk configuration the user used otherwise
158
- disk_default_config = {
159
- autodelete_disk: true,
160
- disk_size: 10,
161
- disk_type: "pd-standard",
249
+ # These defaults cannot live in default_config: their absence is what
250
+ # tells us which of the two configuration styles the user chose.
251
+ config[:disks] =
252
+ if old_disk_configuration_present?
253
+ { disk1: legacy_disk_config }
254
+ elsif new_disk_configuration_present?
255
+ normalize_disks(config[:disks])
256
+ else
257
+ { disk1: DISK_DEFAULT_CONFIG.merge(boot: true) }
258
+ end
259
+ end
260
+
261
+ # Builds the single boot disk described by the deprecated
262
+ # `autodelete_disk`, `disk_size` and `disk_type` options.
263
+ #
264
+ # @return [Hash] the normalised boot disk configuration
265
+ # @raise [RuntimeError] if the configured disk type is not valid
266
+ # @api private
267
+ def legacy_disk_config
268
+ disk_config = {
269
+ boot: true,
270
+ autodelete_disk: config.fetch(:autodelete_disk, DISK_DEFAULT_CONFIG[:autodelete_disk]),
271
+ disk_size: config.fetch(:disk_size, DISK_DEFAULT_CONFIG[:disk_size]),
272
+ disk_type: config.fetch(:disk_type, DISK_DEFAULT_CONFIG[:disk_type]),
162
273
  }
163
274
 
164
- if old_disk_configuration_present?
165
- # If the old disk configuration is used,
166
- # we'll convert it to the new one
167
- config[:disks] = {
168
- disk1: {
169
- boot: true,
170
- autodelete_disk: config.fetch(:autodelete_disk, disk_default_config[:autodelete_disk]),
171
- disk_size: config.fetch(:disk_size, disk_default_config[:disk_size]),
172
- disk_type: config.fetch(:disk_type, disk_default_config[:disk_type]),
173
- },
174
- }
175
- raise "Disk type #{config[:disks][:disk1][:disk_type]} is not valid" unless valid_disk_type?(config[:disks][:disk1][:disk_type])
176
- elsif new_disk_configuration_present?
177
- # If the new disk configuration is present, ensure that for
178
- # every disk the needed configuration is set
179
- boot_disk_counter = 0
180
- config[:disks].each do |disk_name, disk_config|
181
- # te&/ => te
182
- raise "Disk name invalid. Must match #{DISK_NAME_REGEX}." unless valid_disk_name?(disk_name)
183
-
184
- # Update the config for the disk with the fixed config
185
- config[:disks][disk_name.to_sym] = disk_default_config.merge(disk_config)
186
-
187
- # Since the config was altered, we can't use disk_config (as it will be different or keys will not be present)
188
- raise "Disk type #{config[:disks][disk_name.to_sym][:disk_type]} for disk #{disk_name} is not valid" unless valid_disk_type?(config[:disks][disk_name.to_sym][:disk_type])
189
-
190
- unless disk_config[:boot].nil?
191
- boot_disk_counter += 1
192
- raise "Boot disk cannot be local SSD." if disk_config[:disk_type] == "local-ssd"
193
- end
275
+ raise "Disk type #{disk_config[:disk_type]} is not valid" unless valid_disk_type?(disk_config[:disk_type])
194
276
 
195
- if disk_config[:disk_type] == "local-ssd"
196
- raise "#{disk_name}: Cannot use 'disk_size' with local SSD. They always have 375 GB (https://cloud.google.com/compute/docs/disks/#localssds)." unless disk_config[:disk_size].nil?
277
+ disk_config
278
+ end
197
279
 
198
- # Since disk_size is set to 10 in default_config, it needs to be adjusted for local SSDs
199
- config[:disks][disk_name.to_sym][:disk_size] = nil
200
- end
201
- end
202
- if boot_disk_counter == 0
203
- first_disk = config[:disks].first[0]
204
- first_config = config[:disks].first[1]
205
- config[:disks][first_disk] = first_config.merge({ boot: true })
206
- warn("No bootdisk found - Assuming first disk will be boot disk")
207
- elsif boot_disk_counter > 1
208
- raise "More than one boot disk specified"
209
- end
210
- elsif !new_disk_configuration_present?
211
- # If no new disk configuration is present,
212
- # we'll set up the default configuration for the new style
213
- config[:disks] = {
214
- "disk1": disk_default_config.merge({ boot: true }),
215
- }
280
+ # Applies defaults to and validates every entry of a user-supplied
281
+ # `disks` hash, then ensures exactly one disk is marked bootable.
282
+ #
283
+ # Builds a new hash rather than mutating the one being iterated, so that
284
+ # string keys from `kitchen.yml` can be symbolised safely.
285
+ #
286
+ # @param disks [Hash] the raw `disks` configuration, keyed by disk name
287
+ # @return [Hash{Symbol => Hash}] the normalised disk configuration
288
+ # @raise [RuntimeError] if a disk name or type is invalid, or more than
289
+ # one boot disk is specified
290
+ # @api private
291
+ def normalize_disks(disks)
292
+ normalized = disks.each_with_object({}) do |(disk_name, disk_config), memo|
293
+ raise "Disk name invalid. Must match #{DISK_NAME_REGEX}." unless valid_disk_name?(disk_name)
294
+
295
+ memo[disk_name.to_sym] = normalize_disk(disk_name, disk_config)
216
296
  end
297
+
298
+ assign_boot_disk(normalized)
217
299
  end
218
300
 
301
+ # Applies the disk defaults to one disk entry and validates the result.
302
+ #
303
+ # @param disk_name [String, Symbol] the disk's name, used in error messages
304
+ # @param disk_config [Hash] the user-supplied configuration for this disk
305
+ # @return [Hash] the disk configuration with defaults applied
306
+ # @raise [RuntimeError] if the disk type is invalid, a local SSD is
307
+ # marked bootable, or a size is given for a local SSD
308
+ # @api private
309
+ def normalize_disk(disk_name, disk_config)
310
+ normalized = DISK_DEFAULT_CONFIG.merge(disk_config)
311
+
312
+ unless valid_disk_type?(normalized[:disk_type])
313
+ raise "Disk type #{normalized[:disk_type]} for disk #{disk_name} is not valid"
314
+ end
315
+
316
+ return normalized unless local_ssd?(normalized)
317
+
318
+ raise "Boot disk cannot be local SSD." if normalized[:boot]
319
+
320
+ unless disk_config[:disk_size].nil?
321
+ raise "#{disk_name}: Cannot use 'disk_size' with local SSD. They always have " \
322
+ "#{LOCAL_SSD_SIZE_GB} GB (https://cloud.google.com/compute/docs/disks/#localssds)."
323
+ end
324
+
325
+ # disk_size defaults to 10 above, which must not be sent for a local SSD.
326
+ normalized.merge(disk_size: nil)
327
+ end
328
+
329
+ # Ensures exactly one disk in the set is marked as the boot disk,
330
+ # promoting the first eligible disk when the user flagged none.
331
+ #
332
+ # A disk is eligible unless it is a local SSD, which cannot boot, or the
333
+ # user explicitly set `boot: false` on it.
334
+ #
335
+ # @param disks [Hash{Symbol => Hash}] the normalised disk configuration
336
+ # @return [Hash{Symbol => Hash}] the configuration with one boot disk
337
+ # @raise [RuntimeError] if more than one boot disk is specified, no disks
338
+ # were given, or no disk is eligible to boot
339
+ # @api private
340
+ def assign_boot_disk(disks)
341
+ boot_disks = disks.select { |_disk_name, disk_config| disk_config[:boot] }
342
+
343
+ raise "More than one boot disk specified" if boot_disks.size > 1
344
+ return disks unless boot_disks.empty?
345
+
346
+ raise "No disks specified" if disks.empty?
347
+
348
+ bootable = disks.find do |_disk_name, disk_config|
349
+ !local_ssd?(disk_config) && disk_config[:boot] != false
350
+ end
351
+
352
+ if bootable.nil?
353
+ raise "No boot disk specified, and no disk is eligible to become one. " \
354
+ "Local SSDs cannot boot, and disks set to 'boot: false' are excluded."
355
+ end
356
+
357
+ disk_name = bootable.first
358
+ warn("No bootdisk found - Assuming #{disk_name} will be boot disk")
359
+ disks.merge(disk_name => disks[disk_name].merge(boot: true))
360
+ end
361
+
362
+ # Whether a disk configuration describes a local SSD.
363
+ #
364
+ # @param disk_config [Hash] a disk configuration
365
+ # @return [Boolean] true if the disk type is `local-ssd`
366
+ # @api private
367
+ def local_ssd?(disk_config)
368
+ disk_config[:disk_type] == LOCAL_SSD_TYPE
369
+ end
370
+
371
+ # Validates the driver configuration against the GCE API, raising on the
372
+ # first problem found and warning about ambiguous or deprecated settings.
373
+ #
374
+ # @return [void]
375
+ # @raise [RuntimeError] if any configured project, zone, region, machine
376
+ # type, network, subnet, image or disk setting is invalid
219
377
  def validate!
220
378
  raise "Project #{config[:project]} is not a valid project" unless valid_project?
221
379
  raise "Either zone or region must be specified" unless config[:zone] || config[:region]
@@ -239,6 +397,9 @@ module Kitchen
239
397
  warn("These configs are deprecated - consider using new disks configuration") if old_disk_configuration_present?
240
398
  end
241
399
 
400
+ # Memoised, authorised Compute Engine API client.
401
+ #
402
+ # @return [Google::Apis::ComputeV1::ComputeService] the API client
242
403
  def connection
243
404
  return @connection unless @connection.nil?
244
405
 
@@ -252,6 +413,9 @@ module Kitchen
252
413
  @connection
253
414
  end
254
415
 
416
+ # Application default credentials scoped for Compute Engine.
417
+ #
418
+ # @return [Google::Auth::Credentials] the resolved credentials
255
419
  def authorization
256
420
  @authorization ||= Google::Auth.get_application_default(
257
421
  [
@@ -261,10 +425,18 @@ module Kitchen
261
425
  )
262
426
  end
263
427
 
428
+ # Whether the suite's transport is WinRM, implying a Windows guest.
429
+ #
430
+ # @return [Boolean] true when the transport is WinRM
264
431
  def winrm_transport?
265
432
  instance.transport.name.casecmp("winrm") == 0
266
433
  end
267
434
 
435
+ # Resets the Windows password for the transport's user and stores it in
436
+ # the state file. A no-op for non-WinRM transports.
437
+ #
438
+ # @param server_name [String] the GCE instance name
439
+ # @return [void]
268
440
  def update_windows_password(server_name)
269
441
  return unless winrm_transport?
270
442
 
@@ -282,9 +454,14 @@ module Kitchen
282
454
  opts[:timeout] = config[:winpass_timeout] unless config[:winpass_timeout].nil?
283
455
  state[:password] = GoogleComputeWindowsPassword.new(**opts).new_password
284
456
 
285
- info("Password reset complete on #{server_name} complete.")
457
+ info("Password reset complete on #{server_name}.")
286
458
  end
287
459
 
460
+ # Runs an API call and reports whether it succeeded, swallowing client
461
+ # errors so callers can use it as a validity predicate.
462
+ #
463
+ # @yield the API call to attempt
464
+ # @return [Boolean] true if the call succeeded, false on a client error
288
465
  def check_api_call(&block)
289
466
  yield
290
467
  rescue Google::Apis::ClientError => e
@@ -294,94 +471,161 @@ module Kitchen
294
471
  true
295
472
  end
296
473
 
474
+ # Whether the configured project exists and is reachable.
475
+ #
476
+ # @return [Boolean] true if the project is valid
297
477
  def valid_project?
298
478
  check_api_call { connection.get_project(project) }
299
479
  end
300
480
 
481
+ # Whether the configured machine type exists in the target zone.
482
+ #
483
+ # @return [Boolean] true if the machine type is valid
301
484
  def valid_machine_type?
302
485
  return false if config[:machine_type].nil?
303
486
 
304
487
  check_api_call { connection.get_machine_type(project, zone, config[:machine_type]) }
305
488
  end
306
489
 
490
+ # Whether the configured network exists in the network project.
491
+ #
492
+ # @return [Boolean] true if the network is valid
307
493
  def valid_network?
308
494
  return false if config[:network].nil?
309
495
 
310
496
  check_api_call { connection.get_network(network_project, config[:network]) }
311
497
  end
312
498
 
499
+ # Whether the configured subnet exists in the subnet project and region.
500
+ #
501
+ # @return [Boolean] true if the subnet is valid
313
502
  def valid_subnet?
314
503
  return false if config[:subnet].nil?
315
504
 
316
505
  check_api_call { connection.get_subnetwork(subnet_project, region, config[:subnet]) }
317
506
  end
318
507
 
508
+ # Whether the configured zone exists in the project.
509
+ #
510
+ # @return [Boolean] true if the zone is valid
319
511
  def valid_zone?
320
512
  return false if config[:zone].nil?
321
513
 
322
514
  check_api_call { connection.get_zone(project, config[:zone]) }
323
515
  end
324
516
 
517
+ # Whether the configured region exists in the project.
518
+ #
519
+ # @return [Boolean] true if the region is valid
325
520
  def valid_region?
326
521
  return false if config[:region].nil?
327
522
 
328
523
  check_api_call { connection.get_region(project, config[:region]) }
329
524
  end
330
525
 
526
+ # Whether a disk type exists in the target zone.
527
+ #
528
+ # @param disk_type [String, nil] the disk type to check
529
+ # @return [Boolean] true if the disk type is valid
331
530
  def valid_disk_type?(disk_type)
332
531
  return false if disk_type.nil?
333
532
 
334
533
  check_api_call { connection.get_disk_type(project, zone, disk_type) }
335
534
  end
336
535
 
536
+ # Whether a disk name matches {DISK_NAME_REGEX} in full.
537
+ #
538
+ # @param disk_name [String, Symbol] the disk name to check
539
+ # @return [Boolean] true if the whole name matches the pattern
337
540
  def valid_disk_name?(disk_name)
338
- disk_name.to_s.match(DISK_NAME_REGEX).to_s.length == disk_name.length
541
+ disk_name.to_s.match?(/\A#{DISK_NAME_REGEX}\z/)
339
542
  end
340
543
 
544
+ # Whether an image exists in the image project.
545
+ #
546
+ # @param image [String] the image name, defaulting to the configured one
547
+ # @return [Boolean] true if the image exists
341
548
  def image_exist?(image = image_name)
342
549
  check_api_call { connection.get_image(image_project, image) }
343
550
  end
344
551
 
552
+ # Whether a GCE instance exists in the target project and zone.
553
+ #
554
+ # @param server_name [String] the instance name
555
+ # @return [Boolean] true if the instance exists
345
556
  def server_exist?(server_name)
346
557
  check_api_call { server_instance(server_name) }
347
558
  end
348
559
 
560
+ # The configured GCP project.
561
+ #
562
+ # @return [String] the project ID
349
563
  def project
350
564
  config[:project]
351
565
  end
352
566
 
567
+ # Name of the boot image, resolved from the image family when only a
568
+ # family was configured.
569
+ #
570
+ # @return [String] the image name
353
571
  def image_name
354
572
  @image_name ||= config[:image_name] || image_name_for_family(config[:image_family])
355
573
  end
356
574
 
575
+ # Project searched for images, defaulting to the instance's own project.
576
+ #
577
+ # @return [String] the image project ID
357
578
  def image_project
358
579
  config[:image_project].nil? ? project : config[:image_project]
359
580
  end
360
581
 
582
+ # Project searched for subnets, defaulting to the instance's own project.
583
+ #
584
+ # @return [String] the subnet project ID
361
585
  def subnet_project
362
586
  config[:subnet_project].nil? ? project : config[:subnet_project]
363
587
  end
364
588
 
589
+ # Project searched for networks, defaulting to the instance's own project.
590
+ #
591
+ # @return [String] the network project ID
365
592
  def network_project
366
593
  config[:network_project].nil? ? project : config[:network_project]
367
594
  end
368
595
 
596
+ # The static internal IP to assign, if one was configured.
597
+ #
598
+ # @return [String, nil] the internal IP address
369
599
  def network_ip
370
600
  config[:network_ip]
371
601
  end
372
602
 
603
+ # The target region, derived from the zone when not configured directly.
604
+ #
605
+ # @return [String] the region name
373
606
  def region
374
607
  config[:region].nil? ? region_for_zone : config[:region]
375
608
  end
376
609
 
610
+ # Looks up which region the target zone belongs to.
611
+ #
612
+ # @return [String] the region name
377
613
  def region_for_zone
378
614
  @region_for_zone ||= connection.get_zone(project, zone).region.split("/").last
379
615
  end
380
616
 
617
+ # The target zone, taken from the state file or configuration, or chosen
618
+ # at random from the configured region.
619
+ #
620
+ # @return [String] the zone name
381
621
  def zone
382
622
  @zone ||= state[:zone] || config[:zone] || find_zone
383
623
  end
384
624
 
625
+ # Picks a random zone that is up in the configured region.
626
+ #
627
+ # @return [String] the chosen zone name
628
+ # @raise [RuntimeError] if no zone in the region is available
385
629
  def find_zone
386
630
  zone = zones_in_region.sample
387
631
  raise "Unable to find a suitable zone in #{region}" if zone.nil?
@@ -389,6 +633,9 @@ module Kitchen
389
633
  zone.name
390
634
  end
391
635
 
636
+ # All zones in the configured region whose status is `UP`.
637
+ #
638
+ # @return [Array<Google::Apis::ComputeV1::Zone>] the available zones
392
639
  def zones_in_region
393
640
  connection.list_zones(project).items.select do |zone|
394
641
  zone.status == "UP" &&
@@ -396,26 +643,49 @@ module Kitchen
396
643
  end
397
644
  end
398
645
 
646
+ # Fetches a GCE instance.
647
+ #
648
+ # @param server_name [String] the instance name
649
+ # @return [Google::Apis::ComputeV1::Instance] the instance
399
650
  def server_instance(server_name)
400
651
  connection.get_instance(project, zone, server_name)
401
652
  end
402
653
 
654
+ # The IP address Test Kitchen should connect to, honouring
655
+ # `use_private_ip`.
656
+ #
657
+ # @param server [Google::Apis::ComputeV1::Instance] the instance
658
+ # @return [String] the IP address
403
659
  def ip_address_for(server)
404
660
  config[:use_private_ip] ? private_ip_for(server) : public_ip_for(server)
405
661
  end
406
662
 
663
+ # The instance's internal IP address.
664
+ #
665
+ # @param server [Google::Apis::ComputeV1::Instance] the instance
666
+ # @return [String] the private IP address
667
+ # @raise [RuntimeError] if the instance has no network interface
407
668
  def private_ip_for(server)
408
669
  server.network_interfaces.first.network_ip
409
670
  rescue NoMethodError
410
671
  raise "Unable to determine private IP for instance"
411
672
  end
412
673
 
674
+ # The instance's external NAT IP address.
675
+ #
676
+ # @param server [Google::Apis::ComputeV1::Instance] the instance
677
+ # @return [String] the public IP address
678
+ # @raise [RuntimeError] if the instance has no external access config
413
679
  def public_ip_for(server)
414
680
  server.network_interfaces.first.access_configs.first.nat_ip
415
681
  rescue NoMethodError
416
682
  raise "Unable to determine public IP for instance"
417
683
  end
418
684
 
685
+ # Assembles the full instance definition sent to the GCE API.
686
+ #
687
+ # @param server_name [String] the instance name
688
+ # @return [Google::Apis::ComputeV1::Instance] the instance to create
419
689
  def create_instance_object(server_name)
420
690
  inst_obj = Google::Apis::ComputeV1::Instance.new
421
691
  inst_obj.name = server_name
@@ -432,10 +702,14 @@ module Kitchen
432
702
  inst_obj
433
703
  end
434
704
 
705
+ # Builds a unique, GCE-legal instance name, falling back to a UUID when
706
+ # the Test Kitchen instance name would make it too long.
707
+ #
708
+ # @return [String] the instance name
435
709
  def generate_server_name
436
710
  name = config[:inst_name] || "tk-#{instance.name.downcase}-#{SecureRandom.hex(3)}"
437
711
 
438
- if name.length > 63
712
+ if name.length > MAX_INSTANCE_NAME_LENGTH
439
713
  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.")
440
714
  name = "tk-#{SecureRandom.uuid}"
441
715
  end
@@ -443,6 +717,11 @@ module Kitchen
443
717
  name.gsub(/([^-a-z0-9])/, "-")
444
718
  end
445
719
 
720
+ # Builds every disk for the instance, creating standalone persistent
721
+ # disks up front where required. The boot disk is always listed first.
722
+ #
723
+ # @param server_name [String] the instance name, used to derive disk names
724
+ # @return [Array<Google::Apis::ComputeV1::AttachedDisk>] the disks
446
725
  def create_disks(server_name)
447
726
  disks = []
448
727
  config[:disks].each do |disk_name, disk_config|
@@ -450,7 +729,7 @@ module Kitchen
450
729
  if disk_config[:boot]
451
730
  disk = create_local_disk(unique_disk_name, disk_config)
452
731
  disks.unshift(disk)
453
- elsif (disk_config[:disk_type] == "local-ssd") || disk_config[:custom_image]
732
+ elsif local_ssd?(disk_config) || disk_config[:custom_image]
454
733
  disk = create_local_disk(unique_disk_name, disk_config)
455
734
  disks.push(disk)
456
735
  else
@@ -461,32 +740,43 @@ module Kitchen
461
740
  disks
462
741
  end
463
742
 
743
+ # Builds a disk created inline with the instance, from either the boot
744
+ # image, a custom image, or as local SSD scratch space.
745
+ #
746
+ # @param unique_disk_name [String] the disk's name
747
+ # @param disk_config [Hash] the normalised disk configuration
748
+ # @return [Google::Apis::ComputeV1::AttachedDisk] the disk
464
749
  def create_local_disk(unique_disk_name, disk_config)
465
750
  disk = Google::Apis::ComputeV1::AttachedDisk.new
466
751
  # Specifies the parameters for a new disk that will be created alongside the new instance.
467
752
  params = Google::Apis::ComputeV1::AttachedDiskInitializeParams.new
468
- disk.boot = true if !disk_config[:boot].nil? && disk_config[:boot].to_s == "true"
753
+ disk.boot = true if disk_config[:boot]
469
754
  disk.auto_delete = disk_config[:autodelete_disk]
470
755
  params.disk_size_gb = disk_config[:disk_size]
471
756
  params.disk_type = disk_type_url_for(disk_config[:disk_type])
472
757
 
473
- if disk_config[:disk_type] == "local-ssd"
474
- info("Creating a 375 GB local ssd as scratch disk (https://cloud.google.com/compute/docs/disks/#localssds).")
758
+ if local_ssd?(disk_config)
759
+ info("Creating a #{LOCAL_SSD_SIZE_GB} GB local ssd as scratch disk (https://cloud.google.com/compute/docs/disks/#localssds).")
475
760
  disk.type = "SCRATCH"
476
761
  elsif disk.boot
477
762
  info("Creating a #{disk_config[:disk_size]} GB boot disk named #{unique_disk_name} from image #{image_name}...")
478
- params.source_image = boot_disk_source_image unless disk_config[:disk_type] == "local-ssd"
479
- params.disk_name = unique_disk_name unless disk_config[:disk_type] == "local-ssd"
763
+ params.source_image = boot_disk_source_image
764
+ params.disk_name = unique_disk_name
480
765
  else
481
766
  info("Creating a #{disk_config[:disk_size]} GB extra disk named #{unique_disk_name} from image #{disk_config[:custom_image]}...")
482
- params.source_image = image_url(disk_config[:custom_image]) unless disk_config[:disk_type] == "local-ssd"
483
- params.disk_name = unique_disk_name unless disk_config[:disk_type] == "local-ssd"
484
-
767
+ params.source_image = image_url(disk_config[:custom_image])
768
+ params.disk_name = unique_disk_name
485
769
  end
486
770
  disk.initialize_params = params
487
771
  disk
488
772
  end
489
773
 
774
+ # Creates a standalone persistent disk, waits for it to become ready, and
775
+ # returns a reference attaching it to the instance.
776
+ #
777
+ # @param unique_disk_name [String] the disk's name
778
+ # @param disk_config [Hash] the normalised disk configuration
779
+ # @return [Google::Apis::ComputeV1::AttachedDisk] the attachment
490
780
  def create_attached_disk(unique_disk_name, disk_config)
491
781
  disk = Google::Apis::ComputeV1::Disk.new
492
782
  disk.name = unique_disk_name
@@ -495,6 +785,7 @@ module Kitchen
495
785
 
496
786
  info("Creating a #{disk_config[:disk_size]} GB disk named #{unique_disk_name}...")
497
787
  wait_for_operation(connection.insert_disk(project, zone, disk))
788
+ created_disk_names << unique_disk_name
498
789
  info("Waiting for disk to be ready...")
499
790
  wait_for_status("READY") { connection.get_disk(project, zone, unique_disk_name) }
500
791
  info("Disk created successfully.")
@@ -504,6 +795,28 @@ module Kitchen
504
795
  attached_disk
505
796
  end
506
797
 
798
+ # Names of the standalone disks this driver created during the current
799
+ # action, tracked so they can be cleaned up if creation fails.
800
+ #
801
+ # @return [Array<String>] the created disk names
802
+ def created_disk_names
803
+ @created_disk_names ||= []
804
+ end
805
+
806
+ # Deletes every standalone disk created during a failed create, so a
807
+ # partial run does not leave billable disks behind.
808
+ #
809
+ # @return [void]
810
+ def delete_created_disks
811
+ created_disk_names.each { |disk_name| delete_disk(disk_name) }
812
+ created_disk_names.clear
813
+ end
814
+
815
+ # Deletes a standalone persistent disk, tolerating one that is already
816
+ # gone.
817
+ #
818
+ # @param unique_disk_name [String] the disk's name
819
+ # @return [void]
507
820
  def delete_disk(unique_disk_name)
508
821
  begin
509
822
  connection.get_disk(project, zone, unique_disk_name)
@@ -517,35 +830,64 @@ module Kitchen
517
830
  info("Disk #{unique_disk_name} deleted successfully.")
518
831
  end
519
832
 
833
+ # Partial URL identifying a disk type in the target zone.
834
+ #
835
+ # @param type [String] the disk type
836
+ # @return [String] the disk type URL
520
837
  def disk_type_url_for(type)
521
838
  "zones/#{zone}/diskTypes/#{type}"
522
839
  end
523
840
 
841
+ # Partial URL identifying a disk in the target project and zone.
842
+ #
843
+ # @param unique_disk_name [String] the disk's name
844
+ # @return [String] the disk's self link
524
845
  def disk_self_link(unique_disk_name)
525
846
  "projects/#{project}/zones/#{zone}/disks/#{unique_disk_name}"
526
847
  end
527
848
 
849
+ # Memoised URL of the image the boot disk is created from.
850
+ #
851
+ # @return [String, nil] the image URL, or nil if the image is missing
528
852
  def boot_disk_source_image
529
853
  @boot_disk_source ||= image_url
530
854
  end
531
855
 
856
+ # URL of an image, provided it exists in the image project.
857
+ #
858
+ # @param image [String] the image name, defaulting to the configured one
859
+ # @return [String, nil] the image URL, or nil if the image is missing
532
860
  def image_url(image = image_name)
533
861
  "projects/#{image_project}/global/images/#{image}" if image_exist?(image)
534
862
  end
535
863
 
864
+ # Resolves the current image name for an image family.
865
+ #
866
+ # @param image_family [String] the image family
867
+ # @return [String] the image name
536
868
  def image_name_for_family(image_family)
537
869
  image = connection.get_image_from_family(image_project, image_family)
538
870
  image.name
539
871
  end
540
872
 
873
+ # Partial URL identifying the machine type in the target zone.
874
+ #
875
+ # @return [String] the machine type URL
541
876
  def machine_type_url
542
877
  "zones/#{zone}/machineTypes/#{config[:machine_type]}"
543
878
  end
544
879
 
880
+ # The configured guest accelerators.
881
+ #
882
+ # @return [Array<Hash>] the accelerator configurations
545
883
  def guest_accelerators
546
884
  config[:guest_accelerators]
547
885
  end
548
886
 
887
+ # Builds accelerator definitions for the instance, skipping any entry
888
+ # that does not name a type and defaulting the count to one.
889
+ #
890
+ # @return [Array<Google::Apis::ComputeV1::AcceleratorConfig>] the accelerators
549
891
  def instance_guest_accelerators
550
892
  guest_accelerator_configs = []
551
893
 
@@ -567,6 +909,10 @@ module Kitchen
567
909
  guest_accelerator_configs
568
910
  end
569
911
 
912
+ # The instance metadata, merging the driver's own keys over any the user
913
+ # configured and adding a WinRM bootstrap script for Windows guests.
914
+ #
915
+ # @return [Hash{String => String}] the metadata
570
916
  def metadata
571
917
  default_metadata = {
572
918
  "created-by" => "test-kitchen",
@@ -584,6 +930,9 @@ module Kitchen
584
930
  config[:metadata].merge(default_metadata)
585
931
  end
586
932
 
933
+ # The metadata in the form the GCE API expects.
934
+ #
935
+ # @return [Google::Apis::ComputeV1::Metadata] the metadata object
587
936
  def instance_metadata
588
937
  Google::Apis::ComputeV1::Metadata.new.tap do |metadata_obj|
589
938
  metadata_obj.items = metadata.each_with_object([]) do |(k, v), memo|
@@ -595,14 +944,23 @@ module Kitchen
595
944
  end
596
945
  end
597
946
 
947
+ # The configured instance labels.
948
+ #
949
+ # @return [Hash] the labels
598
950
  def instance_labels
599
951
  config[:labels]
600
952
  end
601
953
 
954
+ # The username recorded in instance metadata.
955
+ #
956
+ # @return [String] the current user, or `"unknown"`
602
957
  def env_user
603
958
  ENV["USER"] || "unknown"
604
959
  end
605
960
 
961
+ # Builds the instance's single network interface.
962
+ #
963
+ # @return [Array<Google::Apis::ComputeV1::NetworkInterface>] the interface
606
964
  def instance_network_interfaces
607
965
  interface = Google::Apis::ComputeV1::NetworkInterface.new
608
966
  interface.network = network_url if config[:subnet_project].nil?
@@ -613,16 +971,26 @@ module Kitchen
613
971
  Array(interface)
614
972
  end
615
973
 
974
+ # Partial URL identifying the configured network.
975
+ #
976
+ # @return [String] the network URL
616
977
  def network_url
617
978
  "projects/#{network_project}/global/networks/#{config[:network]}"
618
979
  end
619
980
 
981
+ # Partial URL identifying the configured subnet.
982
+ #
983
+ # @return [String, nil] the subnet URL, or nil when no subnet is set
620
984
  def subnet_url
621
985
  return unless config[:subnet]
622
986
 
623
987
  "projects/#{subnet_project}/regions/#{region}/subnetworks/#{config[:subnet]}"
624
988
  end
625
989
 
990
+ # The interface's external access configuration, omitted entirely when
991
+ # `use_private_ip` is set.
992
+ #
993
+ # @return [Array<Google::Apis::ComputeV1::AccessConfig>] the access configs
626
994
  def interface_access_configs
627
995
  return [] if config[:use_private_ip]
628
996
 
@@ -633,30 +1001,55 @@ module Kitchen
633
1001
  Array(access_config)
634
1002
  end
635
1003
 
1004
+ # The instance's scheduling options.
1005
+ #
1006
+ # @return [Google::Apis::ComputeV1::Scheduling] the scheduling options
636
1007
  def instance_scheduling
637
1008
  Google::Apis::ComputeV1::Scheduling.new.tap do |scheduling|
638
- scheduling.automatic_restart = auto_restart?.to_s
639
- scheduling.preemptible = preemptible?.to_s
1009
+ scheduling.automatic_restart = auto_restart?
1010
+ scheduling.preemptible = preemptible?
640
1011
  scheduling.on_host_maintenance = migrate_setting
641
1012
  end
642
1013
  end
643
1014
 
1015
+ # Whether the instance should be preemptible.
1016
+ #
1017
+ # @return [Boolean] true if preemptible
644
1018
  def preemptible?
645
- config[:preemptible]
1019
+ config[:preemptible] ? true : false
646
1020
  end
647
1021
 
1022
+ # Whether the instance may live-migrate. Always false when preemptible,
1023
+ # which GCE does not allow to migrate.
1024
+ #
1025
+ # @return [Boolean] true if live migration is enabled
648
1026
  def auto_migrate?
649
- preemptible? ? false : config[:auto_migrate]
1027
+ return false if preemptible?
1028
+
1029
+ config[:auto_migrate] ? true : false
650
1030
  end
651
1031
 
1032
+ # Whether the instance should restart automatically. Always false when
1033
+ # preemptible, which GCE does not allow to auto-restart.
1034
+ #
1035
+ # @return [Boolean] true if auto-restart is enabled
652
1036
  def auto_restart?
653
- preemptible? ? false : config[:auto_restart]
1037
+ return false if preemptible?
1038
+
1039
+ config[:auto_restart] ? true : false
654
1040
  end
655
1041
 
1042
+ # The host maintenance behaviour implied by {#auto_migrate?}.
1043
+ #
1044
+ # @return [String] `"MIGRATE"` or `"TERMINATE"`
656
1045
  def migrate_setting
657
1046
  auto_migrate? ? "MIGRATE" : "TERMINATE"
658
1047
  end
659
1048
 
1049
+ # The service account and scopes attached to the instance.
1050
+ #
1051
+ # @return [Array<Google::Apis::ComputeV1::ServiceAccount>, nil] the
1052
+ # service accounts, or nil when no scopes are configured
660
1053
  def instance_service_accounts
661
1054
  return if config[:service_account_scopes].nil? || config[:service_account_scopes].empty?
662
1055
 
@@ -667,28 +1060,54 @@ module Kitchen
667
1060
  Array(service_account)
668
1061
  end
669
1062
 
1063
+ # Expands a scope alias or bare scope name into a full OAuth 2.0 URL,
1064
+ # passing through anything that is already one.
1065
+ #
1066
+ # @param scope [String] the scope, alias or URL
1067
+ # @return [String] the fully-qualified scope URL
670
1068
  def service_account_scope_url(scope)
671
1069
  return scope if scope.start_with?("https://www.googleapis.com/auth/")
672
1070
 
673
1071
  "https://www.googleapis.com/auth/#{translate_scope_alias(scope)}"
674
1072
  end
675
1073
 
1074
+ # Translates a `gcloud` scope alias into its scope path, returning the
1075
+ # input unchanged when it is not a known alias.
1076
+ #
1077
+ # @param scope_alias [String] the alias to translate
1078
+ # @return [String] the scope path
676
1079
  def translate_scope_alias(scope_alias)
677
1080
  SCOPE_ALIAS_MAP.fetch(scope_alias, scope_alias)
678
1081
  end
679
1082
 
1083
+ # The configured network tags in the form the GCE API expects.
1084
+ #
1085
+ # @return [Google::Apis::ComputeV1::Tags] the tags object
680
1086
  def instance_tags
681
1087
  Google::Apis::ComputeV1::Tags.new.tap { |tag_obj| tag_obj.items = config[:tags] }
682
1088
  end
683
1089
 
1090
+ # How long, in seconds, to wait for an operation or status change.
1091
+ #
1092
+ # @return [Integer] the wait timeout
684
1093
  def wait_time
685
1094
  config[:wait_time]
686
1095
  end
687
1096
 
1097
+ # How long, in seconds, to sleep between status polls.
1098
+ #
1099
+ # @return [Integer] the poll interval
688
1100
  def refresh_rate
689
1101
  config[:refresh_rate]
690
1102
  end
691
1103
 
1104
+ # Polls the yielded resource until it reports the requested status,
1105
+ # logging each status change.
1106
+ #
1107
+ # @param requested_status [String] the status to wait for
1108
+ # @yieldreturn [#status] the resource to poll
1109
+ # @return [void]
1110
+ # @raise [Timeout::Error] if the status is not reached within {#wait_time}
692
1111
  def wait_for_status(requested_status, &block)
693
1112
  last_status = ""
694
1113
 
@@ -714,6 +1133,12 @@ module Kitchen
714
1133
  end
715
1134
  end
716
1135
 
1136
+ # Waits for a zone operation to finish and raises if it reported errors.
1137
+ #
1138
+ # @param operation [Google::Apis::ComputeV1::Operation] the operation
1139
+ # @return [void]
1140
+ # @raise [RuntimeError] if the operation completed with errors
1141
+ # @raise [Timeout::Error] if the operation did not finish in time
717
1142
  def wait_for_operation(operation)
718
1143
  operation_name = operation.name
719
1144
 
@@ -729,6 +1154,11 @@ module Kitchen
729
1154
  raise "Operation #{operation_name} failed."
730
1155
  end
731
1156
 
1157
+ # Waits until the suite's transport can reach the instance, destroying it
1158
+ # if it never becomes reachable.
1159
+ #
1160
+ # @return [void]
1161
+ # @raise [StandardError] if the server cannot be reached
732
1162
  def wait_for_server
733
1163
  instance.transport.connection(state).wait_until_ready
734
1164
  rescue
@@ -737,10 +1167,18 @@ module Kitchen
737
1167
  raise
738
1168
  end
739
1169
 
1170
+ # Fetches the current state of a zone operation.
1171
+ #
1172
+ # @param operation_name [String] the operation name
1173
+ # @return [Google::Apis::ComputeV1::Operation] the operation
740
1174
  def zone_operation(operation_name)
741
1175
  connection.get_zone_operation(project, zone, operation_name)
742
1176
  end
743
1177
 
1178
+ # The errors a zone operation reported, if any.
1179
+ #
1180
+ # @param operation_name [String] the operation name
1181
+ # @return [Array<Google::Apis::ComputeV1::Operation::Error::Error>] the errors
744
1182
  def operation_errors(operation_name)
745
1183
  operation = zone_operation(operation_name)
746
1184
  return [] if operation.error.nil?
@@ -18,8 +18,16 @@
18
18
  # limitations under the License.
19
19
  #
20
20
 
21
+ # Test Kitchen's top-level namespace.
21
22
  module Kitchen
23
+ # Namespace for Test Kitchen driver plugins.
22
24
  module Driver
23
- GCE_VERSION = "2.7.0"
25
+ # Version of the kitchen-google gem.
26
+ #
27
+ # Maintained by release-please, which rewrites the literal below on each
28
+ # release. Keep it on one line.
29
+ #
30
+ # @return [String] the gem version
31
+ GCE_VERSION = "2.8.0"
24
32
  end
25
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: 2.7.0
4
+ version: 2.8.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: 2026-07-02 00:00:00.000000000 Z
11
+ date: 2026-08-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: gcewinpass