kitchen-google 0.3.0 → 1.0.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.
@@ -1,8 +1,9 @@
1
1
  # -*- coding: utf-8 -*-
2
2
  #
3
3
  # Author:: Andrew Leonard (<andy@hurricane-ridge.com>)
4
+ # Author:: Chef Partner Engineering (<partnereng@chef.io>)
4
5
  #
5
- # Copyright (C) 2013-2014, Andrew Leonard
6
+ # Copyright (C) 2013-2016, Andrew Leonard and Chef Software, Inc.
6
7
  #
7
8
  # Licensed under the Apache License, Version 2.0 (the "License");
8
9
  # you may not use this file except in compliance with the License.
@@ -16,154 +17,513 @@
16
17
  # See the License for the specific language governing permissions and
17
18
  # limitations under the License.
18
19
 
19
- require 'fog'
20
- require 'securerandom'
21
-
22
- require 'kitchen'
20
+ require "gcewinpass"
21
+ require "google/apis/compute_v1"
22
+ require "kitchen"
23
+ require "kitchen/driver/gce_version"
24
+ require "securerandom"
23
25
 
24
26
  module Kitchen
25
27
  module Driver
26
28
  # Google Compute Engine driver for Test Kitchen
27
29
  #
28
30
  # @author Andrew Leonard <andy@hurricane-ridge.com>
29
- class Gce < Kitchen::Driver::SSHBase
30
- default_config :area, 'us-central1'
31
+ class Gce < Kitchen::Driver::Base
32
+ attr_accessor :state
33
+
34
+ SCOPE_ALIAS_MAP = {
35
+ "bigquery" => "bigquery",
36
+ "cloud-platform" => "cloud-platform",
37
+ "compute-ro" => "compute.readonly",
38
+ "compute-rw" => "compute",
39
+ "datastore" => "datastore",
40
+ "logging-write" => "logging.write",
41
+ "monitoring" => "monitoring",
42
+ "monitoring-write" => "monitoring.write",
43
+ "service-control" => "servicecontrol",
44
+ "service-management" => "service.management",
45
+ "sql" => "sqlservice",
46
+ "sql-admin" => "sqlservice.admin",
47
+ "storage-full" => "devstorage.full_control",
48
+ "storage-ro" => "devstorage.read_only",
49
+ "storage-rw" => "devstorage.read_write",
50
+ "taskqueue" => "taskqueue",
51
+ "useraccounts-ro" => "cloud.useraccounts.readonly",
52
+ "useraccounts-rw" => "cloud.useraccounts",
53
+ "userinfo-email" => "userinfo.email",
54
+ }
55
+
56
+ kitchen_driver_api_version 2
57
+ plugin_version Kitchen::Driver::GCE_VERSION
58
+
59
+ required_config :project
60
+ required_config :image_name
61
+
62
+ default_config :region, nil
63
+ default_config :zone, nil
64
+
31
65
  default_config :autodelete_disk, true
32
66
  default_config :disk_size, 10
33
- default_config :machine_type, 'n1-standard-1'
34
- default_config :network, 'default'
67
+ default_config :disk_type, "pd-standard"
68
+ default_config :machine_type, "n1-standard-1"
69
+ default_config :network, "default"
35
70
  default_config :inst_name, nil
36
- default_config :service_accounts, nil
71
+ default_config :service_account_name, "default"
72
+ default_config :service_account_scopes, []
37
73
  default_config :tags, []
38
- default_config :username, ENV['USER']
39
- default_config :zone_name, nil
40
- default_config :google_key_location, nil
41
- default_config :google_json_key_location, nil
42
74
  default_config :preemptible, false
43
75
  default_config :auto_restart, false
76
+ default_config :auto_migrate, false
77
+ default_config :image_project, nil
78
+ default_config :email, nil
79
+ default_config :use_private_ip, false
80
+ default_config :wait_time, 600
81
+ default_config :refresh_rate, 2
44
82
 
45
- required_config :google_client_email
46
- required_config :google_project
47
- required_config :image_name
83
+ def name
84
+ "Google Compute (GCE)"
85
+ end
48
86
 
49
87
  def create(state)
50
- return if state[:server_id]
88
+ @state = state
89
+ return if state[:server_name]
51
90
 
52
- instance = create_instance
53
- state[:server_id] = instance.identity
91
+ validate!
54
92
 
55
- info("GCE instance <#{state[:server_id]}> created.")
93
+ server_name = generate_server_name
56
94
 
57
- wait_for_up_instance(instance, state)
95
+ info("Creating GCE instance <#{server_name}> in project #{project}, zone #{zone}...")
96
+ operation = connection.insert_instance(project, zone, create_instance_object(server_name))
58
97
 
59
- rescue Fog::Errors::Error, Excon::Errors::Error => ex
60
- raise ActionFailed, ex.message
98
+ info("Zone operation #{operation.name} created. Waiting for it to complete...")
99
+ wait_for_operation(operation)
100
+
101
+ server = server_instance(server_name)
102
+ state[:server_name] = server_name
103
+ state[:hostname] = ip_address_for(server)
104
+ state[:zone] = zone
105
+
106
+ info("Server <#{server_name}> created.")
107
+
108
+ update_windows_password(server_name)
109
+
110
+ info("Waiting for server <#{server_name}> to be ready...")
111
+ wait_for_server
112
+
113
+ info("GCE instance <#{server_name}> created and ready.")
114
+ rescue => e
115
+ error("Error encountered during server creation: #{e.class}: #{e.message}")
116
+ destroy(state)
117
+ raise
61
118
  end
62
119
 
63
120
  def destroy(state)
64
- return if state[:server_id].nil?
121
+ @state = state
122
+ server_name = state[:server_name]
123
+ return if server_name.nil?
124
+
125
+ unless server_exist?(server_name)
126
+ info("GCE instance <#{server_name}> does not exist - assuming it has been already destroyed.")
127
+ return
128
+ end
129
+
130
+ info("Destroying GCE instance <#{server_name}>...")
131
+ wait_for_operation(connection.delete_instance(project, zone, server_name))
132
+ info("GCE instance <#{server_name}> destroyed.")
65
133
 
66
- instance = connection.servers.get(state[:server_id])
67
- instance.destroy unless instance.nil?
68
- info("GCE instance <#{state[:server_id]}> destroyed.")
69
- state.delete(:server_id)
134
+ state.delete(:server_name)
70
135
  state.delete(:hostname)
136
+ state.delete(:zone)
71
137
  end
72
138
 
73
- private
139
+ def validate!
140
+ raise "Project #{config[:project]} is not a valid project" unless valid_project?
141
+ raise "Either zone or region must be specified" unless config[:zone] || config[:region]
142
+ raise "'any' is no longer a valid region" if config[:region] == "any"
143
+ raise "Zone #{config[:zone]} is not a valid zone" if config[:zone] && !valid_zone?
144
+ raise "Region #{config[:region]} is not a valid region" if config[:region] && !valid_region?
145
+ raise "Machine type #{config[:machine_type]} is not valid" unless valid_machine_type?
146
+ raise "Disk type #{config[:disk_type]} is not valid" unless valid_disk_type?
147
+ raise "Network #{config[:network]} is not valid" unless valid_network?
148
+ raise "Email address of GCE user is not set" if winrm_transport? && config[:email].nil?
149
+
150
+ warn("Both zone and region specified - region will be ignored.") if config[:zone] && config[:region]
151
+ end
74
152
 
75
153
  def connection
76
- options = {
77
- provider: 'google',
78
- google_client_email: config[:google_client_email],
79
- google_project: config[:google_project]
80
- }
154
+ return @connection unless @connection.nil?
81
155
 
82
- [
83
- :google_key_location,
84
- :google_json_key_location
85
- ].each do |k|
86
- options[k] = config[k] unless config[k].nil?
156
+ @connection = Google::Apis::ComputeV1::ComputeService.new
157
+ @connection.authorization = authorization
158
+ @connection.client_options = Google::Apis::ClientOptions.new.tap do |opts|
159
+ opts.application_name = "kitchen-google"
160
+ opts.application_version = Kitchen::Driver::GCE_VERSION
87
161
  end
88
162
 
89
- Fog::Compute.new(options)
163
+ @connection
90
164
  end
91
165
 
92
- def create_disk
93
- disk = connection.disks.create(
94
- name: config[:inst_name],
95
- size_gb: config[:disk_size],
96
- zone_name: config[:zone_name],
97
- source_image: config[:image_name]
166
+ def authorization
167
+ @authorization ||= Google::Auth.get_application_default(
168
+ [
169
+ "https://www.googleapis.com/auth/cloud-platform",
170
+ "https://www.googleapis.com/auth/compute",
171
+ ]
98
172
  )
173
+ end
174
+
175
+ def winrm_transport?
176
+ instance.transport.name.downcase == "winrm"
177
+ end
178
+
179
+ def update_windows_password(server_name)
180
+ return unless winrm_transport?
181
+
182
+ username = instance.transport[:username]
183
+
184
+ info("Resetting the Windows password for user #{username} on #{server_name}...")
185
+
186
+ state[:password] = GoogleComputeWindowsPassword.new(
187
+ project: project,
188
+ zone: zone,
189
+ instance_name: server_name,
190
+ email: config[:email],
191
+ username: username
192
+ ).new_password
193
+
194
+ info("Password reset complete on #{server_name} complete.")
195
+ end
196
+
197
+ def check_api_call(&block)
198
+ block.call
199
+ rescue Google::Apis::ClientError => e
200
+ debug("API error: #{e.message}")
201
+ false
202
+ else
203
+ true
204
+ end
205
+
206
+ def valid_project?
207
+ check_api_call { connection.get_project(project) }
208
+ end
209
+
210
+ def valid_machine_type?
211
+ return false if config[:machine_type].nil?
212
+ check_api_call { connection.get_machine_type(project, zone, config[:machine_type]) }
213
+ end
214
+
215
+ def valid_network?
216
+ return false if config[:network].nil?
217
+ check_api_call { connection.get_network(project, config[:network]) }
218
+ end
219
+
220
+ def valid_zone?
221
+ return false if config[:zone].nil?
222
+ check_api_call { connection.get_zone(project, config[:zone]) }
223
+ end
224
+
225
+ def valid_region?
226
+ return false if config[:region].nil?
227
+ check_api_call { connection.get_region(project, config[:region]) }
228
+ end
229
+
230
+ def valid_disk_type?
231
+ return false if config[:disk_type].nil?
232
+ check_api_call { connection.get_disk_type(project, zone, config[:disk_type]) }
233
+ end
234
+
235
+ def image_exist?(image_project, image_name)
236
+ check_api_call { connection.get_image(image_project, image_name) }
237
+ end
238
+
239
+ def server_exist?(server_name)
240
+ check_api_call { server_instance(server_name) }
241
+ end
242
+
243
+ def project
244
+ config[:project]
245
+ end
246
+
247
+ def region
248
+ config[:region]
249
+ end
250
+
251
+ def zone
252
+ @zone ||= state[:zone] || config[:zone] || find_zone
253
+ end
254
+
255
+ def find_zone
256
+ zone = zones_in_region.sample
257
+ raise "Unable to find a suitable zone in #{region}" if zone.nil?
258
+
259
+ zone.name
260
+ end
261
+
262
+ def zones_in_region
263
+ connection.list_zones(project).items.select do |zone|
264
+ zone.status == "UP" &&
265
+ zone.region.split("/").last == region
266
+ end
267
+ end
268
+
269
+ def server_instance(server_name)
270
+ connection.get_instance(project, zone, server_name)
271
+ end
272
+
273
+ def ip_address_for(server)
274
+ config[:use_private_ip] ? private_ip_for(server) : public_ip_for(server)
275
+ end
276
+
277
+ def private_ip_for(server)
278
+ server.network_interfaces.first.network_ip
279
+ rescue NoMethodError
280
+ raise "Unable to determine private IP for instance"
281
+ end
282
+
283
+ def public_ip_for(server)
284
+ server.network_interfaces.first.access_configs.first.nat_ip
285
+ rescue NoMethodError
286
+ raise "Unable to determine public IP for instance"
287
+ end
99
288
 
100
- disk.wait_for { disk.ready? }
289
+ def create_instance_object(server_name)
290
+ inst_obj = Google::Apis::ComputeV1::Instance.new
291
+ inst_obj.name = server_name
292
+ inst_obj.disks = [boot_disk(server_name)]
293
+ inst_obj.machine_type = machine_type_url
294
+ inst_obj.metadata = instance_metadata
295
+ inst_obj.network_interfaces = instance_network_interfaces
296
+ inst_obj.scheduling = instance_scheduling
297
+ inst_obj.service_accounts = instance_service_accounts unless instance_service_accounts.nil?
298
+ inst_obj.tags = instance_tags
299
+
300
+ inst_obj
301
+ end
302
+
303
+ def generate_server_name
304
+ name = "tk-#{instance.name.downcase}-#{SecureRandom.hex(3)}"
305
+
306
+ if name.length > 63
307
+ 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.")
308
+ name = "tk-#{SecureRandom.uuid}"
309
+ end
310
+
311
+ name.gsub(/([^-a-z0-9])/, "-")
312
+ end
313
+
314
+ def boot_disk(server_name)
315
+ disk = Google::Apis::ComputeV1::AttachedDisk.new
316
+ params = Google::Apis::ComputeV1::AttachedDiskInitializeParams.new
317
+
318
+ disk.boot = true
319
+ disk.auto_delete = config[:autodelete_disk]
320
+ params.disk_name = server_name
321
+ params.disk_size_gb = config[:disk_size]
322
+ params.disk_type = disk_type_url_for(config[:disk_type])
323
+ params.source_image = disk_image_url
324
+
325
+ disk.initialize_params = params
101
326
  disk
102
327
  end
103
328
 
104
- def create_instance
105
- config[:region] ||= config[:area]
329
+ def disk_type_url_for(type)
330
+ "zones/#{zone}/diskTypes/#{type}"
331
+ end
332
+
333
+ def disk_image_url
334
+ # if the user provided an image_project, assume they want it, no questions asked
335
+ return image_url_for(config[:image_project], config[:image_name]) unless config[:image_project].nil?
336
+
337
+ # no image project has been provided. We'll first check the user's project for the image.
338
+ url = image_url_for(project, config[:image_name])
339
+ return url unless url.nil?
106
340
 
107
- config[:inst_name] ||= generate_inst_name
108
- config[:zone_name] ||= select_zone
341
+ # Image not found in user's project. Is there a public project this image might exist in?
342
+ public_project = public_project_for_image(config[:image_name])
343
+ if public_project
344
+ return image_url_for(public_project, config[:image_name])
345
+ end
109
346
 
110
- disk = create_disk
111
- create_server(disk)
347
+ # No image in user's project or public project, so it doesn't exist.
348
+ nil
112
349
  end
113
350
 
114
- def create_server(disk)
115
- connection.servers.create(
116
- name: config[:inst_name],
117
- disks: [disk.get_as_boot_disk(true, config[:autodelete_disk])],
118
- machine_type: config[:machine_type],
119
- network: config[:network],
120
- service_accounts: config[:service_accounts],
121
- tags: config[:tags],
122
- zone_name: config[:zone_name],
123
- public_key_path: config[:public_key_path],
124
- username: config[:username],
125
- preemptible: config[:preemptible],
126
- on_host_maintenance: config[:preemptible] ? 'TERMINATE': 'MIGRATE',
127
- auto_restart: config[:auto_restart]
128
- )
351
+ def image_url_for(image_project, image_name)
352
+ return "projects/#{image_project}/global/images/#{image_name}" if image_exist?(image_project, image_name)
129
353
  end
130
354
 
131
- def generate_inst_name
132
- # Inspired by generate_name from kitchen-rackspace
133
- name = instance.name.downcase
134
- name.gsub!(/([^-a-z0-9])/, '-')
135
- name = 't' + name unless name =~ /^[a-z]/
136
- base_name = name[0..25] # UUID is 36 chars, max name length 63
137
- gen_name = "#{base_name}-#{SecureRandom.uuid}"
138
- unless gen_name =~ /^[a-z]([-a-z0-9]*[a-z0-9])?$/
139
- fail "Invalid generated instance name: #{gen_name}"
355
+ def public_project_for_image(image)
356
+ case image
357
+ when /centos/
358
+ "centos-cloud"
359
+ when /container-vm/
360
+ "google-containers"
361
+ when /coreos/
362
+ "coreos-cloud"
363
+ when /debian/
364
+ "debian-cloud"
365
+ when /opensuse-cloud/
366
+ "opensuse-cloud"
367
+ when /rhel/
368
+ "rhel-cloud"
369
+ when /sles/
370
+ "suse-cloud"
371
+ when /ubuntu/
372
+ "ubuntu-os-cloud"
373
+ when /windows/
374
+ "windows-cloud"
140
375
  end
141
- gen_name
142
376
  end
143
377
 
144
- def select_zone
145
- if config[:region] == 'any'
146
- zone_regexp = /^[a-z]+\-/
147
- else
148
- zone_regexp = /^#{config[:region]}\-/
378
+ def machine_type_url
379
+ "zones/#{zone}/machineTypes/#{config[:machine_type]}"
380
+ end
381
+
382
+ def instance_metadata
383
+ metadata = {
384
+ "created-by" => "test-kitchen",
385
+ "test-kitchen-instance" => instance.name,
386
+ "test-kitchen-user" => env_user,
387
+ }
388
+
389
+ Google::Apis::ComputeV1::Metadata.new.tap do |metadata_obj|
390
+ metadata_obj.items = metadata.each_with_object([]) do |(k, v), memo|
391
+ memo << Google::Apis::ComputeV1::Metadata::Item.new.tap do |item|
392
+ item.key = k
393
+ item.value = v
394
+ end
395
+ end
149
396
  end
150
- zones = connection.zones.select do |z|
151
- z.status == 'UP' && z.name.match(zone_regexp)
397
+ end
398
+
399
+ def env_user
400
+ ENV["USER"] || "unknown"
401
+ end
402
+
403
+ def instance_network_interfaces
404
+ interface = Google::Apis::ComputeV1::NetworkInterface.new
405
+ interface.network = network_url
406
+ interface.access_configs = interface_access_configs
407
+
408
+ Array(interface)
409
+ end
410
+
411
+ def network_url
412
+ "projects/#{project}/global/networks/#{config[:network]}"
413
+ end
414
+
415
+ def interface_access_configs
416
+ return [] if config[:use_private_ip]
417
+
418
+ access_config = Google::Apis::ComputeV1::AccessConfig.new
419
+ access_config.name = "External NAT"
420
+ access_config.type = "ONE_TO_ONE_NAT"
421
+
422
+ Array(access_config)
423
+ end
424
+
425
+ def instance_scheduling
426
+ Google::Apis::ComputeV1::Scheduling.new.tap do |scheduling|
427
+ scheduling.automatic_restart = config[:auto_restart].to_s
428
+ scheduling.preemptible = config[:preemptible].to_s
429
+ scheduling.on_host_maintenance = migrate_setting
152
430
  end
153
- fail 'No up zones in region' unless zones.length >= 1
154
- zones.sample.name
155
431
  end
156
432
 
157
- def wait_for_up_instance(instance, state)
158
- instance.wait_for do
159
- print '.'
160
- ready?
433
+ def migrate_setting
434
+ config[:auto_migrate] ? "MIGRATE" : "TERMINATE"
435
+ end
436
+
437
+ def instance_service_accounts
438
+ return if config[:service_account_scopes].nil? || config[:service_account_scopes].empty?
439
+
440
+ service_account = Google::Apis::ComputeV1::ServiceAccount.new
441
+ service_account.email = config[:service_account_name]
442
+ service_account.scopes = config[:service_account_scopes].map { |scope| service_account_scope_url(scope) }
443
+
444
+ Array(service_account)
445
+ end
446
+
447
+ def service_account_scope_url(scope)
448
+ return scope if scope.start_with?("https://www.googleapis.com/auth/")
449
+ "https://www.googleapis.com/auth/#{translate_scope_alias(scope)}"
450
+ end
451
+
452
+ def translate_scope_alias(scope_alias)
453
+ SCOPE_ALIAS_MAP.fetch(scope_alias, scope_alias)
454
+ end
455
+
456
+ def instance_tags
457
+ Google::Apis::ComputeV1::Tags.new.tap { |tag_obj| tag_obj.items = config[:tags] }
458
+ end
459
+
460
+ def wait_time
461
+ config[:wait_time]
462
+ end
463
+
464
+ def refresh_rate
465
+ config[:refresh_rate]
466
+ end
467
+
468
+ def wait_for_status(requested_status, &block)
469
+ last_status = ""
470
+
471
+ begin
472
+ Timeout.timeout(wait_time) do
473
+ loop do
474
+ item = block.call
475
+ current_status = item.status
476
+
477
+ unless last_status == current_status
478
+ last_status = current_status
479
+ info("Current status: #{current_status}")
480
+ end
481
+
482
+ break if current_status == requested_status
483
+
484
+ sleep refresh_rate
485
+ end
486
+ end
487
+ rescue Timeout::Error
488
+ error("Request did not complete in #{wait_time} seconds. Check the Google Cloud Console for more info.")
489
+ raise
490
+ end
491
+ end
492
+
493
+ def wait_for_operation(operation)
494
+ operation_name = operation.name
495
+
496
+ wait_for_status("DONE") { zone_operation(operation_name) }
497
+
498
+ errors = operation_errors(operation_name)
499
+ return if errors.empty?
500
+
501
+ errors.each do |error|
502
+ error("#{error.code}: #{error.message}")
503
+ end
504
+
505
+ raise "Operation #{operation_name} failed."
506
+ end
507
+
508
+ def wait_for_server
509
+ begin
510
+ instance.transport.connection(state).wait_until_ready
511
+ rescue
512
+ error("Server not reachable. Destroying server...")
513
+ destroy(state)
514
+ raise
161
515
  end
162
- print '(server ready)'
163
- state[:hostname] = instance.public_ip_address ||
164
- instance.private_ip_address
165
- wait_for_sshd(state[:hostname], config[:username])
166
- puts '(ssh ready)'
516
+ end
517
+
518
+ def zone_operation(operation_name)
519
+ connection.get_zone_operation(project, zone, operation_name)
520
+ end
521
+
522
+ def operation_errors(operation_name)
523
+ operation = zone_operation(operation_name)
524
+ return [] if operation.error.nil?
525
+
526
+ operation.error.errors
167
527
  end
168
528
  end
169
529
  end