kitchen-google 2.6.2 → 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: 1ba54579dc46b82f89c6470539122fcf6e8cc321e78512986367e362ad83d0fa
4
- data.tar.gz: c7901cccf154de7f51a5ef15c0dc8056d1992437a91fab3d286aaa25860790c0
3
+ metadata.gz: d96f1f6b4c605799e8b3af57a4e8966a786f808f1c3e5e2c201d34e306da454d
4
+ data.tar.gz: 2f8f62792d8c6b15a568dd5f8abdf177acafad3762df1e04ccacf4b69f502fe2
5
5
  SHA512:
6
- metadata.gz: a6a3144e29524184e7a6fb6f6220f7986a50774706a4e0165c295e12dff5d5f95e2a4c5646af8f003e03a9cdd03e6f879edc5841e1065be6451b3ed6851d42bd
7
- data.tar.gz: 764929405242980be16d6d13f147003f20fafcd90ee3c3237fef5c64c21d3e2d9624596615694463ec33671dd6b316602eadf4421e94d471b52f47da6791afb6
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",
@@ -80,16 +97,60 @@ module Kitchen
80
97
  default_config :use_private_ip, false
81
98
  default_config :wait_time, 600
82
99
  default_config :refresh_rate, 2
100
+ default_config :winpass_timeout, nil
83
101
  default_config :guest_accelerators, []
84
102
  default_config :metadata, {}
85
103
  default_config :labels, {}
86
104
 
105
+ # Pattern a GCE disk name must match in full.
106
+ #
107
+ # @return [Regexp] the permitted disk-name pattern
87
108
  DISK_NAME_REGEX = /(?:[a-z](?:[-a-z0-9]{0,61}[a-z0-9])?)/
88
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
89
138
  def name
90
139
  "Google Compute (GCE)"
91
140
  end
92
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
93
154
  def create(state)
94
155
  @state = state
95
156
  return if state[:server_name]
@@ -120,10 +181,24 @@ module Kitchen
120
181
  info("GCE instance <#{server_name}> created and ready.")
121
182
  rescue => e
122
183
  error("Error encountered during server creation: #{e.class}: #{e.message}")
123
- 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
124
191
  raise
125
192
  end
126
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]
127
202
  def destroy(state)
128
203
  @state = state
129
204
  server_name = state[:server_name]
@@ -143,78 +218,162 @@ module Kitchen
143
218
  state.delete(:zone)
144
219
  end
145
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
146
225
  def old_disk_configuration_present?
147
226
  !config[:autodelete_disk].nil? || !config[:disk_size].nil? || !config[:disk_type].nil?
148
227
  end
149
228
 
229
+ # Whether the multi-disk `disks` option is configured.
230
+ #
231
+ # @return [Boolean] true if `disks` is set
150
232
  def new_disk_configuration_present?
151
233
  !config[:disks].nil?
152
234
  end
153
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
154
248
  def create_disks_config
155
- # This can't be present in default_config because we couldn't
156
- # determine which disk configuration the user used otherwise
157
- disk_default_config = {
158
- autodelete_disk: true,
159
- disk_size: 10,
160
- 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]),
161
273
  }
162
274
 
163
- if old_disk_configuration_present?
164
- # If the old disk configuration is used,
165
- # we'll convert it to the new one
166
- config[:disks] = {
167
- disk1: {
168
- boot: true,
169
- autodelete_disk: config.fetch(:autodelete_disk, disk_default_config[:autodelete_disk]),
170
- disk_size: config.fetch(:disk_size, disk_default_config[:disk_size]),
171
- disk_type: config.fetch(:disk_type, disk_default_config[:disk_type]),
172
- },
173
- }
174
- raise "Disk type #{config[:disks][:disk1][:disk_type]} is not valid" unless valid_disk_type?(config[:disks][:disk1][:disk_type])
175
- elsif new_disk_configuration_present?
176
- # If the new disk configuration is present, ensure that for
177
- # every disk the needed configuration is set
178
- boot_disk_counter = 0
179
- config[:disks].each do |disk_name, disk_config|
180
- # te&/ => te
181
- raise "Disk name invalid. Must match #{DISK_NAME_REGEX}." unless valid_disk_name?(disk_name)
182
-
183
- # Update the config for the disk with the fixed config
184
- config[:disks][disk_name.to_sym] = disk_default_config.merge(disk_config)
185
-
186
- # Since the config was altered, we can't use disk_config (as it will be different or keys will not be present)
187
- 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])
188
-
189
- unless disk_config[:boot].nil?
190
- boot_disk_counter += 1
191
- raise "Boot disk cannot be local SSD." if disk_config[:disk_type] == "local-ssd"
192
- end
275
+ raise "Disk type #{disk_config[:disk_type]} is not valid" unless valid_disk_type?(disk_config[:disk_type])
193
276
 
194
- if disk_config[:disk_type] == "local-ssd"
195
- 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
196
279
 
197
- # Since disk_size is set to 10 in default_config, it needs to be adjusted for local SSDs
198
- config[:disks][disk_name.to_sym][:disk_size] = nil
199
- end
200
- end
201
- if boot_disk_counter == 0
202
- first_disk = config[:disks].first[0]
203
- first_config = config[:disks].first[1]
204
- config[:disks][first_disk] = first_config.merge({ boot: true })
205
- warn("No bootdisk found - Assuming first disk will be boot disk")
206
- elsif boot_disk_counter > 1
207
- raise "More than one boot disk specified"
208
- end
209
- elsif !new_disk_configuration_present?
210
- # If no new disk configuration is present,
211
- # we'll set up the default configuration for the new style
212
- config[:disks] = {
213
- "disk1": disk_default_config.merge({ boot: true }),
214
- }
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)
296
+ end
297
+
298
+ assign_boot_disk(normalized)
299
+ end
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)."
215
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)
216
327
  end
217
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
218
377
  def validate!
219
378
  raise "Project #{config[:project]} is not a valid project" unless valid_project?
220
379
  raise "Either zone or region must be specified" unless config[:zone] || config[:region]
@@ -238,6 +397,9 @@ module Kitchen
238
397
  warn("These configs are deprecated - consider using new disks configuration") if old_disk_configuration_present?
239
398
  end
240
399
 
400
+ # Memoised, authorised Compute Engine API client.
401
+ #
402
+ # @return [Google::Apis::ComputeV1::ComputeService] the API client
241
403
  def connection
242
404
  return @connection unless @connection.nil?
243
405
 
@@ -251,6 +413,9 @@ module Kitchen
251
413
  @connection
252
414
  end
253
415
 
416
+ # Application default credentials scoped for Compute Engine.
417
+ #
418
+ # @return [Google::Auth::Credentials] the resolved credentials
254
419
  def authorization
255
420
  @authorization ||= Google::Auth.get_application_default(
256
421
  [
@@ -260,10 +425,18 @@ module Kitchen
260
425
  )
261
426
  end
262
427
 
428
+ # Whether the suite's transport is WinRM, implying a Windows guest.
429
+ #
430
+ # @return [Boolean] true when the transport is WinRM
263
431
  def winrm_transport?
264
432
  instance.transport.name.casecmp("winrm") == 0
265
433
  end
266
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]
267
440
  def update_windows_password(server_name)
268
441
  return unless winrm_transport?
269
442
 
@@ -271,17 +444,24 @@ module Kitchen
271
444
 
272
445
  info("Resetting the Windows password for user #{username} on #{server_name}...")
273
446
 
274
- state[:password] = GoogleComputeWindowsPassword.new(
275
- project:,
276
- zone:,
447
+ opts = {
448
+ project: project,
449
+ zone: zone,
277
450
  instance_name: server_name,
278
- email: config[:email],
279
- username:
280
- ).new_password
451
+ email: config[:email],
452
+ username: username,
453
+ }
454
+ opts[:timeout] = config[:winpass_timeout] unless config[:winpass_timeout].nil?
455
+ state[:password] = GoogleComputeWindowsPassword.new(**opts).new_password
281
456
 
282
- info("Password reset complete on #{server_name} complete.")
457
+ info("Password reset complete on #{server_name}.")
283
458
  end
284
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
285
465
  def check_api_call(&block)
286
466
  yield
287
467
  rescue Google::Apis::ClientError => e
@@ -291,94 +471,161 @@ module Kitchen
291
471
  true
292
472
  end
293
473
 
474
+ # Whether the configured project exists and is reachable.
475
+ #
476
+ # @return [Boolean] true if the project is valid
294
477
  def valid_project?
295
478
  check_api_call { connection.get_project(project) }
296
479
  end
297
480
 
481
+ # Whether the configured machine type exists in the target zone.
482
+ #
483
+ # @return [Boolean] true if the machine type is valid
298
484
  def valid_machine_type?
299
485
  return false if config[:machine_type].nil?
300
486
 
301
487
  check_api_call { connection.get_machine_type(project, zone, config[:machine_type]) }
302
488
  end
303
489
 
490
+ # Whether the configured network exists in the network project.
491
+ #
492
+ # @return [Boolean] true if the network is valid
304
493
  def valid_network?
305
494
  return false if config[:network].nil?
306
495
 
307
496
  check_api_call { connection.get_network(network_project, config[:network]) }
308
497
  end
309
498
 
499
+ # Whether the configured subnet exists in the subnet project and region.
500
+ #
501
+ # @return [Boolean] true if the subnet is valid
310
502
  def valid_subnet?
311
503
  return false if config[:subnet].nil?
312
504
 
313
505
  check_api_call { connection.get_subnetwork(subnet_project, region, config[:subnet]) }
314
506
  end
315
507
 
508
+ # Whether the configured zone exists in the project.
509
+ #
510
+ # @return [Boolean] true if the zone is valid
316
511
  def valid_zone?
317
512
  return false if config[:zone].nil?
318
513
 
319
514
  check_api_call { connection.get_zone(project, config[:zone]) }
320
515
  end
321
516
 
517
+ # Whether the configured region exists in the project.
518
+ #
519
+ # @return [Boolean] true if the region is valid
322
520
  def valid_region?
323
521
  return false if config[:region].nil?
324
522
 
325
523
  check_api_call { connection.get_region(project, config[:region]) }
326
524
  end
327
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
328
530
  def valid_disk_type?(disk_type)
329
531
  return false if disk_type.nil?
330
532
 
331
533
  check_api_call { connection.get_disk_type(project, zone, disk_type) }
332
534
  end
333
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
334
540
  def valid_disk_name?(disk_name)
335
- 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/)
336
542
  end
337
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
338
548
  def image_exist?(image = image_name)
339
549
  check_api_call { connection.get_image(image_project, image) }
340
550
  end
341
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
342
556
  def server_exist?(server_name)
343
557
  check_api_call { server_instance(server_name) }
344
558
  end
345
559
 
560
+ # The configured GCP project.
561
+ #
562
+ # @return [String] the project ID
346
563
  def project
347
564
  config[:project]
348
565
  end
349
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
350
571
  def image_name
351
572
  @image_name ||= config[:image_name] || image_name_for_family(config[:image_family])
352
573
  end
353
574
 
575
+ # Project searched for images, defaulting to the instance's own project.
576
+ #
577
+ # @return [String] the image project ID
354
578
  def image_project
355
579
  config[:image_project].nil? ? project : config[:image_project]
356
580
  end
357
581
 
582
+ # Project searched for subnets, defaulting to the instance's own project.
583
+ #
584
+ # @return [String] the subnet project ID
358
585
  def subnet_project
359
586
  config[:subnet_project].nil? ? project : config[:subnet_project]
360
587
  end
361
588
 
589
+ # Project searched for networks, defaulting to the instance's own project.
590
+ #
591
+ # @return [String] the network project ID
362
592
  def network_project
363
593
  config[:network_project].nil? ? project : config[:network_project]
364
594
  end
365
595
 
596
+ # The static internal IP to assign, if one was configured.
597
+ #
598
+ # @return [String, nil] the internal IP address
366
599
  def network_ip
367
600
  config[:network_ip]
368
601
  end
369
602
 
603
+ # The target region, derived from the zone when not configured directly.
604
+ #
605
+ # @return [String] the region name
370
606
  def region
371
607
  config[:region].nil? ? region_for_zone : config[:region]
372
608
  end
373
609
 
610
+ # Looks up which region the target zone belongs to.
611
+ #
612
+ # @return [String] the region name
374
613
  def region_for_zone
375
614
  @region_for_zone ||= connection.get_zone(project, zone).region.split("/").last
376
615
  end
377
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
378
621
  def zone
379
622
  @zone ||= state[:zone] || config[:zone] || find_zone
380
623
  end
381
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
382
629
  def find_zone
383
630
  zone = zones_in_region.sample
384
631
  raise "Unable to find a suitable zone in #{region}" if zone.nil?
@@ -386,6 +633,9 @@ module Kitchen
386
633
  zone.name
387
634
  end
388
635
 
636
+ # All zones in the configured region whose status is `UP`.
637
+ #
638
+ # @return [Array<Google::Apis::ComputeV1::Zone>] the available zones
389
639
  def zones_in_region
390
640
  connection.list_zones(project).items.select do |zone|
391
641
  zone.status == "UP" &&
@@ -393,26 +643,49 @@ module Kitchen
393
643
  end
394
644
  end
395
645
 
646
+ # Fetches a GCE instance.
647
+ #
648
+ # @param server_name [String] the instance name
649
+ # @return [Google::Apis::ComputeV1::Instance] the instance
396
650
  def server_instance(server_name)
397
651
  connection.get_instance(project, zone, server_name)
398
652
  end
399
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
400
659
  def ip_address_for(server)
401
660
  config[:use_private_ip] ? private_ip_for(server) : public_ip_for(server)
402
661
  end
403
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
404
668
  def private_ip_for(server)
405
669
  server.network_interfaces.first.network_ip
406
670
  rescue NoMethodError
407
671
  raise "Unable to determine private IP for instance"
408
672
  end
409
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
410
679
  def public_ip_for(server)
411
680
  server.network_interfaces.first.access_configs.first.nat_ip
412
681
  rescue NoMethodError
413
682
  raise "Unable to determine public IP for instance"
414
683
  end
415
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
416
689
  def create_instance_object(server_name)
417
690
  inst_obj = Google::Apis::ComputeV1::Instance.new
418
691
  inst_obj.name = server_name
@@ -429,10 +702,14 @@ module Kitchen
429
702
  inst_obj
430
703
  end
431
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
432
709
  def generate_server_name
433
710
  name = config[:inst_name] || "tk-#{instance.name.downcase}-#{SecureRandom.hex(3)}"
434
711
 
435
- if name.length > 63
712
+ if name.length > MAX_INSTANCE_NAME_LENGTH
436
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.")
437
714
  name = "tk-#{SecureRandom.uuid}"
438
715
  end
@@ -440,6 +717,11 @@ module Kitchen
440
717
  name.gsub(/([^-a-z0-9])/, "-")
441
718
  end
442
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
443
725
  def create_disks(server_name)
444
726
  disks = []
445
727
  config[:disks].each do |disk_name, disk_config|
@@ -447,7 +729,7 @@ module Kitchen
447
729
  if disk_config[:boot]
448
730
  disk = create_local_disk(unique_disk_name, disk_config)
449
731
  disks.unshift(disk)
450
- elsif (disk_config[:disk_type] == "local-ssd") || disk_config[:custom_image]
732
+ elsif local_ssd?(disk_config) || disk_config[:custom_image]
451
733
  disk = create_local_disk(unique_disk_name, disk_config)
452
734
  disks.push(disk)
453
735
  else
@@ -458,32 +740,43 @@ module Kitchen
458
740
  disks
459
741
  end
460
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
461
749
  def create_local_disk(unique_disk_name, disk_config)
462
750
  disk = Google::Apis::ComputeV1::AttachedDisk.new
463
751
  # Specifies the parameters for a new disk that will be created alongside the new instance.
464
752
  params = Google::Apis::ComputeV1::AttachedDiskInitializeParams.new
465
- disk.boot = true if !disk_config[:boot].nil? && disk_config[:boot].to_s == "true"
753
+ disk.boot = true if disk_config[:boot]
466
754
  disk.auto_delete = disk_config[:autodelete_disk]
467
755
  params.disk_size_gb = disk_config[:disk_size]
468
756
  params.disk_type = disk_type_url_for(disk_config[:disk_type])
469
757
 
470
- if disk_config[:disk_type] == "local-ssd"
471
- 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).")
472
760
  disk.type = "SCRATCH"
473
761
  elsif disk.boot
474
762
  info("Creating a #{disk_config[:disk_size]} GB boot disk named #{unique_disk_name} from image #{image_name}...")
475
- params.source_image = boot_disk_source_image unless disk_config[:disk_type] == "local-ssd"
476
- 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
477
765
  else
478
766
  info("Creating a #{disk_config[:disk_size]} GB extra disk named #{unique_disk_name} from image #{disk_config[:custom_image]}...")
479
- params.source_image = image_url(disk_config[:custom_image]) unless disk_config[:disk_type] == "local-ssd"
480
- params.disk_name = unique_disk_name unless disk_config[:disk_type] == "local-ssd"
481
-
767
+ params.source_image = image_url(disk_config[:custom_image])
768
+ params.disk_name = unique_disk_name
482
769
  end
483
770
  disk.initialize_params = params
484
771
  disk
485
772
  end
486
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
487
780
  def create_attached_disk(unique_disk_name, disk_config)
488
781
  disk = Google::Apis::ComputeV1::Disk.new
489
782
  disk.name = unique_disk_name
@@ -492,6 +785,7 @@ module Kitchen
492
785
 
493
786
  info("Creating a #{disk_config[:disk_size]} GB disk named #{unique_disk_name}...")
494
787
  wait_for_operation(connection.insert_disk(project, zone, disk))
788
+ created_disk_names << unique_disk_name
495
789
  info("Waiting for disk to be ready...")
496
790
  wait_for_status("READY") { connection.get_disk(project, zone, unique_disk_name) }
497
791
  info("Disk created successfully.")
@@ -501,6 +795,28 @@ module Kitchen
501
795
  attached_disk
502
796
  end
503
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]
504
820
  def delete_disk(unique_disk_name)
505
821
  begin
506
822
  connection.get_disk(project, zone, unique_disk_name)
@@ -514,35 +830,64 @@ module Kitchen
514
830
  info("Disk #{unique_disk_name} deleted successfully.")
515
831
  end
516
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
517
837
  def disk_type_url_for(type)
518
838
  "zones/#{zone}/diskTypes/#{type}"
519
839
  end
520
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
521
845
  def disk_self_link(unique_disk_name)
522
846
  "projects/#{project}/zones/#{zone}/disks/#{unique_disk_name}"
523
847
  end
524
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
525
852
  def boot_disk_source_image
526
853
  @boot_disk_source ||= image_url
527
854
  end
528
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
529
860
  def image_url(image = image_name)
530
- return "projects/#{image_project}/global/images/#{image}" if image_exist?(image)
861
+ "projects/#{image_project}/global/images/#{image}" if image_exist?(image)
531
862
  end
532
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
533
868
  def image_name_for_family(image_family)
534
869
  image = connection.get_image_from_family(image_project, image_family)
535
870
  image.name
536
871
  end
537
872
 
873
+ # Partial URL identifying the machine type in the target zone.
874
+ #
875
+ # @return [String] the machine type URL
538
876
  def machine_type_url
539
877
  "zones/#{zone}/machineTypes/#{config[:machine_type]}"
540
878
  end
541
879
 
880
+ # The configured guest accelerators.
881
+ #
882
+ # @return [Array<Hash>] the accelerator configurations
542
883
  def guest_accelerators
543
884
  config[:guest_accelerators]
544
885
  end
545
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
546
891
  def instance_guest_accelerators
547
892
  guest_accelerator_configs = []
548
893
 
@@ -564,6 +909,10 @@ module Kitchen
564
909
  guest_accelerator_configs
565
910
  end
566
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
567
916
  def metadata
568
917
  default_metadata = {
569
918
  "created-by" => "test-kitchen",
@@ -581,6 +930,9 @@ module Kitchen
581
930
  config[:metadata].merge(default_metadata)
582
931
  end
583
932
 
933
+ # The metadata in the form the GCE API expects.
934
+ #
935
+ # @return [Google::Apis::ComputeV1::Metadata] the metadata object
584
936
  def instance_metadata
585
937
  Google::Apis::ComputeV1::Metadata.new.tap do |metadata_obj|
586
938
  metadata_obj.items = metadata.each_with_object([]) do |(k, v), memo|
@@ -592,14 +944,23 @@ module Kitchen
592
944
  end
593
945
  end
594
946
 
947
+ # The configured instance labels.
948
+ #
949
+ # @return [Hash] the labels
595
950
  def instance_labels
596
951
  config[:labels]
597
952
  end
598
953
 
954
+ # The username recorded in instance metadata.
955
+ #
956
+ # @return [String] the current user, or `"unknown"`
599
957
  def env_user
600
958
  ENV["USER"] || "unknown"
601
959
  end
602
960
 
961
+ # Builds the instance's single network interface.
962
+ #
963
+ # @return [Array<Google::Apis::ComputeV1::NetworkInterface>] the interface
603
964
  def instance_network_interfaces
604
965
  interface = Google::Apis::ComputeV1::NetworkInterface.new
605
966
  interface.network = network_url if config[:subnet_project].nil?
@@ -610,16 +971,26 @@ module Kitchen
610
971
  Array(interface)
611
972
  end
612
973
 
974
+ # Partial URL identifying the configured network.
975
+ #
976
+ # @return [String] the network URL
613
977
  def network_url
614
978
  "projects/#{network_project}/global/networks/#{config[:network]}"
615
979
  end
616
980
 
981
+ # Partial URL identifying the configured subnet.
982
+ #
983
+ # @return [String, nil] the subnet URL, or nil when no subnet is set
617
984
  def subnet_url
618
985
  return unless config[:subnet]
619
986
 
620
987
  "projects/#{subnet_project}/regions/#{region}/subnetworks/#{config[:subnet]}"
621
988
  end
622
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
623
994
  def interface_access_configs
624
995
  return [] if config[:use_private_ip]
625
996
 
@@ -630,30 +1001,55 @@ module Kitchen
630
1001
  Array(access_config)
631
1002
  end
632
1003
 
1004
+ # The instance's scheduling options.
1005
+ #
1006
+ # @return [Google::Apis::ComputeV1::Scheduling] the scheduling options
633
1007
  def instance_scheduling
634
1008
  Google::Apis::ComputeV1::Scheduling.new.tap do |scheduling|
635
- scheduling.automatic_restart = auto_restart?.to_s
636
- scheduling.preemptible = preemptible?.to_s
1009
+ scheduling.automatic_restart = auto_restart?
1010
+ scheduling.preemptible = preemptible?
637
1011
  scheduling.on_host_maintenance = migrate_setting
638
1012
  end
639
1013
  end
640
1014
 
1015
+ # Whether the instance should be preemptible.
1016
+ #
1017
+ # @return [Boolean] true if preemptible
641
1018
  def preemptible?
642
- config[:preemptible]
1019
+ config[:preemptible] ? true : false
643
1020
  end
644
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
645
1026
  def auto_migrate?
646
- preemptible? ? false : config[:auto_migrate]
1027
+ return false if preemptible?
1028
+
1029
+ config[:auto_migrate] ? true : false
647
1030
  end
648
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
649
1036
  def auto_restart?
650
- preemptible? ? false : config[:auto_restart]
1037
+ return false if preemptible?
1038
+
1039
+ config[:auto_restart] ? true : false
651
1040
  end
652
1041
 
1042
+ # The host maintenance behaviour implied by {#auto_migrate?}.
1043
+ #
1044
+ # @return [String] `"MIGRATE"` or `"TERMINATE"`
653
1045
  def migrate_setting
654
1046
  auto_migrate? ? "MIGRATE" : "TERMINATE"
655
1047
  end
656
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
657
1053
  def instance_service_accounts
658
1054
  return if config[:service_account_scopes].nil? || config[:service_account_scopes].empty?
659
1055
 
@@ -664,28 +1060,54 @@ module Kitchen
664
1060
  Array(service_account)
665
1061
  end
666
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
667
1068
  def service_account_scope_url(scope)
668
1069
  return scope if scope.start_with?("https://www.googleapis.com/auth/")
669
1070
 
670
1071
  "https://www.googleapis.com/auth/#{translate_scope_alias(scope)}"
671
1072
  end
672
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
673
1079
  def translate_scope_alias(scope_alias)
674
1080
  SCOPE_ALIAS_MAP.fetch(scope_alias, scope_alias)
675
1081
  end
676
1082
 
1083
+ # The configured network tags in the form the GCE API expects.
1084
+ #
1085
+ # @return [Google::Apis::ComputeV1::Tags] the tags object
677
1086
  def instance_tags
678
1087
  Google::Apis::ComputeV1::Tags.new.tap { |tag_obj| tag_obj.items = config[:tags] }
679
1088
  end
680
1089
 
1090
+ # How long, in seconds, to wait for an operation or status change.
1091
+ #
1092
+ # @return [Integer] the wait timeout
681
1093
  def wait_time
682
1094
  config[:wait_time]
683
1095
  end
684
1096
 
1097
+ # How long, in seconds, to sleep between status polls.
1098
+ #
1099
+ # @return [Integer] the poll interval
685
1100
  def refresh_rate
686
1101
  config[:refresh_rate]
687
1102
  end
688
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}
689
1111
  def wait_for_status(requested_status, &block)
690
1112
  last_status = ""
691
1113
 
@@ -711,6 +1133,12 @@ module Kitchen
711
1133
  end
712
1134
  end
713
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
714
1142
  def wait_for_operation(operation)
715
1143
  operation_name = operation.name
716
1144
 
@@ -726,6 +1154,11 @@ module Kitchen
726
1154
  raise "Operation #{operation_name} failed."
727
1155
  end
728
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
729
1162
  def wait_for_server
730
1163
  instance.transport.connection(state).wait_until_ready
731
1164
  rescue
@@ -734,10 +1167,18 @@ module Kitchen
734
1167
  raise
735
1168
  end
736
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
737
1174
  def zone_operation(operation_name)
738
1175
  connection.get_zone_operation(project, zone, operation_name)
739
1176
  end
740
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
741
1182
  def operation_errors(operation_name)
742
1183
  operation = zone_operation(operation_name)
743
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.6.2"
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.6.2
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-01-22 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