kitchen-google-as 1.2.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.
@@ -0,0 +1,14 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "chefstyle"
9
+ require "rubocop/rake_task"
10
+ RuboCop::RakeTask.new(:style) do |task|
11
+ task.options << "--display-cop-names"
12
+ end
13
+
14
+ task default: [:spec, :style]
@@ -0,0 +1,28 @@
1
+ # -*- coding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require "kitchen/driver/gce_as_version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "kitchen-google-as"
7
+ s.version = Kitchen::Driver::GCE_VERSION
8
+ s.date = "2016-03-10"
9
+ s.summary = "Kitchen::Driver::Gce"
10
+ s.description = "A Test-Kitchen driver for Google Compute Engine"
11
+ s.authors = ["Andrew Leonard", "Chef Partner Engineering"]
12
+ s.email = ["andy@hurricane-ridge.com", "partnereng@chef.io"]
13
+ s.files = `git ls-files`.split($/)
14
+ s.homepage = "https://github.com/test-kitchen/kitchen-google"
15
+ s.license = "Apache 2.0"
16
+
17
+ s.add_dependency "gcewinpass", "~> 1.0"
18
+ s.add_dependency "google-api-client", "~> 0.9.0"
19
+ s.add_dependency "test-kitchen"
20
+
21
+ s.add_development_dependency "bundler"
22
+ s.add_development_dependency "pry"
23
+ s.add_development_dependency "rake", "~> 10.5"
24
+ s.add_development_dependency "rspec"
25
+ s.add_development_dependency "rubocop"
26
+
27
+ s.required_ruby_version = ">= 2.0"
28
+ end
@@ -0,0 +1,543 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Author:: Andrew Leonard (<andy@hurricane-ridge.com>)
4
+ # Author:: Chef Partner Engineering (<partnereng@chef.io>)
5
+ #
6
+ # Copyright (C) 2013-2016, Andrew Leonard and Chef Software, Inc.
7
+ #
8
+ # Licensed under the Apache License, Version 2.0 (the "License");
9
+ # you may not use this file except in compliance with the License.
10
+ # You may obtain a copy of the License at
11
+ #
12
+ # http://www.apache.org/licenses/LICENSE-2.0
13
+ #
14
+ # Unless required by applicable law or agreed to in writing, software
15
+ # distributed under the License is distributed on an "AS IS" BASIS,
16
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17
+ # See the License for the specific language governing permissions and
18
+ # limitations under the License.
19
+
20
+ require "gcewinpass"
21
+ require "google/apis/compute_v1"
22
+ require "kitchen"
23
+ require "kitchen/driver/gce_as_version"
24
+ require "securerandom"
25
+
26
+ module Kitchen
27
+ module Driver
28
+ # Google Compute Engine driver for Test Kitchen
29
+ #
30
+ # @author Andrew Leonard <andy@hurricane-ridge.com>
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
+
61
+ default_config :region, nil
62
+ default_config :zone, nil
63
+
64
+ default_config :autodelete_disk, true
65
+ default_config :disk_size, 10
66
+ default_config :disk_type, "pd-standard"
67
+ default_config :machine_type, "n1-standard-1"
68
+ default_config :network, "default"
69
+ default_config :subnet, nil
70
+ default_config :inst_name, nil
71
+ default_config :service_account_name, "default"
72
+ default_config :service_account_scopes, []
73
+ default_config :tags, []
74
+ default_config :preemptible, false
75
+ default_config :auto_restart, false
76
+ default_config :auto_migrate, false
77
+ default_config :image_family, nil
78
+ default_config :image_name, nil
79
+ default_config :image_project, nil
80
+ default_config :email, nil
81
+ default_config :use_private_ip, false
82
+ default_config :wait_time, 600
83
+ default_config :refresh_rate, 2
84
+
85
+ def name
86
+ "Google Compute (GCE)"
87
+ end
88
+
89
+ def create(state)
90
+ @state = state
91
+ return if state[:server_name]
92
+
93
+ validate!
94
+
95
+ server_name = generate_server_name
96
+
97
+ info("Creating GCE instance <#{server_name}> in project #{project}, zone #{zone}...")
98
+ operation = connection.insert_instance(project, zone, create_instance_object(server_name))
99
+
100
+ info("Zone operation #{operation.name} created. Waiting for it to complete...")
101
+ wait_for_operation(operation)
102
+
103
+ server = server_instance(server_name)
104
+ state[:server_name] = server_name
105
+ state[:hostname] = ip_address_for(server)
106
+ state[:zone] = zone
107
+
108
+ info("Server <#{server_name}> created.")
109
+
110
+ update_windows_password(server_name)
111
+
112
+ info("Waiting for server <#{server_name}> to be ready...")
113
+ wait_for_server
114
+
115
+ info("GCE instance <#{server_name}> created and ready.")
116
+ rescue => e
117
+ error("Error encountered during server creation: #{e.class}: #{e.message}")
118
+ destroy(state)
119
+ raise
120
+ end
121
+
122
+ def destroy(state)
123
+ @state = state
124
+ server_name = state[:server_name]
125
+ return if server_name.nil?
126
+
127
+ unless server_exist?(server_name)
128
+ info("GCE instance <#{server_name}> does not exist - assuming it has been already destroyed.")
129
+ return
130
+ end
131
+
132
+ info("Destroying GCE instance <#{server_name}>...")
133
+ wait_for_operation(connection.delete_instance(project, zone, server_name))
134
+ info("GCE instance <#{server_name}> destroyed.")
135
+
136
+ state.delete(:server_name)
137
+ state.delete(:hostname)
138
+ state.delete(:zone)
139
+ end
140
+
141
+ def validate!
142
+ raise "Project #{config[:project]} is not a valid project" unless valid_project?
143
+ raise "Either zone or region must be specified" unless config[:zone] || config[:region]
144
+ raise "'any' is no longer a valid region" if config[:region] == "any"
145
+ raise "Zone #{config[:zone]} is not a valid zone" if config[:zone] && !valid_zone?
146
+ raise "Region #{config[:region]} is not a valid region" if config[:region] && !valid_region?
147
+ raise "Machine type #{config[:machine_type]} is not valid" unless valid_machine_type?
148
+ raise "Disk type #{config[:disk_type]} is not valid" unless valid_disk_type?
149
+ raise "Either image family or name must be specified" unless config[:image_family] || config[:image_name]
150
+ raise "Disk image #{config[:image_name]} is not valid - check your image name and image project" if boot_disk_source_image.nil?
151
+ raise "Network #{config[:network]} is not valid" unless valid_network?
152
+ raise "Subnet #{config[:subnet]} is not valid" if config[:subnet] && !valid_subnet?
153
+ raise "Email address of GCE user is not set" if winrm_transport? && config[:email].nil?
154
+
155
+ warn("Both zone and region specified - region will be ignored.") if config[:zone] && config[:region]
156
+ warn("Both image family and name specified - image family will be ignored") if config[:image_family] && config[:image_name]
157
+ warn("Image project not specified - searching current project only") unless config[:image_project]
158
+ warn("Auto-migrate disabled for preemptible instance") if preemptible? && config[:auto_migrate]
159
+ warn("Auto-restart disabled for preemptible instance") if preemptible? && config[:auto_restart]
160
+ end
161
+
162
+ def connection
163
+ return @connection unless @connection.nil?
164
+
165
+ @connection = Google::Apis::ComputeV1::ComputeService.new
166
+ @connection.authorization = authorization
167
+ @connection.client_options = Google::Apis::ClientOptions.new.tap do |opts|
168
+ opts.application_name = "kitchen-google"
169
+ opts.application_version = Kitchen::Driver::GCE_VERSION
170
+ end
171
+
172
+ @connection
173
+ end
174
+
175
+ def authorization
176
+ @authorization ||= Google::Auth.get_application_default(
177
+ [
178
+ "https://www.googleapis.com/auth/cloud-platform",
179
+ "https://www.googleapis.com/auth/compute",
180
+ ]
181
+ )
182
+ end
183
+
184
+ def winrm_transport?
185
+ instance.transport.name.casecmp("winrm") == 0
186
+ end
187
+
188
+ def update_windows_password(server_name)
189
+ return unless winrm_transport?
190
+
191
+ username = instance.transport[:username]
192
+
193
+ info("Resetting the Windows password for user #{username} on #{server_name}...")
194
+
195
+ state[:password] = GoogleComputeWindowsPassword.new(
196
+ project: project,
197
+ zone: zone,
198
+ instance_name: server_name,
199
+ email: config[:email],
200
+ username: username
201
+ ).new_password
202
+
203
+ info("Password reset complete on #{server_name} complete.")
204
+ end
205
+
206
+ def check_api_call(&block)
207
+ yield
208
+ rescue Google::Apis::ClientError => e
209
+ debug("API error: #{e.message}")
210
+ false
211
+ else
212
+ true
213
+ end
214
+
215
+ def valid_project?
216
+ check_api_call { connection.get_project(project) }
217
+ end
218
+
219
+ def valid_machine_type?
220
+ return false if config[:machine_type].nil?
221
+ check_api_call { connection.get_machine_type(project, zone, config[:machine_type]) }
222
+ end
223
+
224
+ def valid_network?
225
+ return false if config[:network].nil?
226
+ check_api_call { connection.get_network(project, config[:network]) }
227
+ end
228
+
229
+ def valid_subnet?
230
+ return false if config[:subnet].nil?
231
+ check_api_call { connection.get_subnetwork(project, region, config[:subnet]) }
232
+ end
233
+
234
+ def valid_zone?
235
+ return false if config[:zone].nil?
236
+ check_api_call { connection.get_zone(project, config[:zone]) }
237
+ end
238
+
239
+ def valid_region?
240
+ return false if config[:region].nil?
241
+ check_api_call { connection.get_region(project, config[:region]) }
242
+ end
243
+
244
+ def valid_disk_type?
245
+ return false if config[:disk_type].nil?
246
+ check_api_call { connection.get_disk_type(project, zone, config[:disk_type]) }
247
+ end
248
+
249
+ def image_exist?
250
+ check_api_call { connection.get_image(image_project, image_name) }
251
+ end
252
+
253
+ def server_exist?(server_name)
254
+ check_api_call { server_instance(server_name) }
255
+ end
256
+
257
+ def project
258
+ config[:project]
259
+ end
260
+
261
+ def image_name
262
+ @image_name ||= config[:image_name] || image_name_for_family(config[:image_family])
263
+ end
264
+
265
+ def image_project
266
+ config[:image_project].nil? ? project : config[:image_project]
267
+ end
268
+
269
+ def region
270
+ config[:region].nil? ? region_for_zone : config[:region]
271
+ end
272
+
273
+ def region_for_zone
274
+ @region_for_zone ||= connection.get_zone(project, zone).region.split("/").last
275
+ end
276
+
277
+ def zone
278
+ @zone ||= state[:zone] || config[:zone] || find_zone
279
+ end
280
+
281
+ def find_zone
282
+ zone = zones_in_region.sample
283
+ raise "Unable to find a suitable zone in #{region}" if zone.nil?
284
+
285
+ zone.name
286
+ end
287
+
288
+ def zones_in_region
289
+ connection.list_zones(project).items.select do |zone|
290
+ zone.status == "UP" &&
291
+ zone.region.split("/").last == region
292
+ end
293
+ end
294
+
295
+ def server_instance(server_name)
296
+ connection.get_instance(project, zone, server_name)
297
+ end
298
+
299
+ def ip_address_for(server)
300
+ config[:use_private_ip] ? private_ip_for(server) : public_ip_for(server)
301
+ end
302
+
303
+ def private_ip_for(server)
304
+ server.network_interfaces.first.network_ip
305
+ rescue NoMethodError
306
+ raise "Unable to determine private IP for instance"
307
+ end
308
+
309
+ def public_ip_for(server)
310
+ server.network_interfaces.first.access_configs.first.nat_ip
311
+ rescue NoMethodError
312
+ raise "Unable to determine public IP for instance"
313
+ end
314
+
315
+ def create_instance_object(server_name)
316
+ inst_obj = Google::Apis::ComputeV1::Instance.new
317
+ inst_obj.name = server_name
318
+ inst_obj.disks = [boot_disk(server_name)]
319
+ inst_obj.machine_type = machine_type_url
320
+ inst_obj.metadata = instance_metadata
321
+ inst_obj.network_interfaces = instance_network_interfaces
322
+ inst_obj.scheduling = instance_scheduling
323
+ inst_obj.service_accounts = instance_service_accounts unless instance_service_accounts.nil?
324
+ inst_obj.tags = instance_tags
325
+
326
+ inst_obj
327
+ end
328
+
329
+ def generate_server_name
330
+ name = "tk-#{instance.name.downcase}-#{SecureRandom.hex(3)}"
331
+
332
+ if name.length > 63
333
+ 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.")
334
+ name = "tk-#{SecureRandom.uuid}"
335
+ end
336
+
337
+ name.gsub(/([^-a-z0-9])/, "-")
338
+ end
339
+
340
+ def boot_disk(server_name)
341
+ disk = Google::Apis::ComputeV1::AttachedDisk.new
342
+ params = Google::Apis::ComputeV1::AttachedDiskInitializeParams.new
343
+
344
+ disk.boot = true
345
+ disk.auto_delete = config[:autodelete_disk]
346
+ params.disk_name = server_name
347
+ params.disk_size_gb = config[:disk_size]
348
+ params.disk_type = disk_type_url_for(config[:disk_type])
349
+ params.source_image = boot_disk_source_image
350
+
351
+ disk.initialize_params = params
352
+ disk
353
+ end
354
+
355
+ def disk_type_url_for(type)
356
+ "zones/#{zone}/diskTypes/#{type}"
357
+ end
358
+
359
+ def boot_disk_source_image
360
+ @boot_disk_source ||= image_url
361
+ end
362
+
363
+ def image_url
364
+ return "projects/#{image_project}/global/images/#{image_name}" if image_exist?
365
+ end
366
+
367
+ def image_name_for_family(image_family)
368
+ image = connection.get_image_from_family(image_project, image_family)
369
+ image.name
370
+ end
371
+
372
+ def machine_type_url
373
+ "zones/#{zone}/machineTypes/#{config[:machine_type]}"
374
+ end
375
+
376
+ def instance_metadata
377
+ metadata = {
378
+ "created-by" => "test-kitchen",
379
+ "test-kitchen-instance" => instance.name,
380
+ "test-kitchen-user" => env_user,
381
+ }
382
+
383
+ Google::Apis::ComputeV1::Metadata.new.tap do |metadata_obj|
384
+ metadata_obj.items = metadata.each_with_object([]) do |(k, v), memo|
385
+ memo << Google::Apis::ComputeV1::Metadata::Item.new.tap do |item|
386
+ item.key = k
387
+ item.value = v
388
+ end
389
+ end
390
+ end
391
+ end
392
+
393
+ def env_user
394
+ ENV["USER"] || "unknown"
395
+ end
396
+
397
+ def instance_network_interfaces
398
+ interface = Google::Apis::ComputeV1::NetworkInterface.new
399
+ interface.network = network_url
400
+ interface.subnetwork = subnet_url if subnet_url
401
+ interface.access_configs = interface_access_configs
402
+
403
+ Array(interface)
404
+ end
405
+
406
+ def network_url
407
+ "projects/#{project}/global/networks/#{config[:network]}"
408
+ end
409
+
410
+ def subnet_url
411
+ return unless config[:subnet]
412
+
413
+ "projects/#{project}/regions/#{region}/subnetworks/#{config[:subnet]}"
414
+ end
415
+
416
+ def interface_access_configs
417
+ return [] if config[:use_private_ip]
418
+
419
+ access_config = Google::Apis::ComputeV1::AccessConfig.new
420
+ access_config.name = "External NAT"
421
+ access_config.type = "ONE_TO_ONE_NAT"
422
+
423
+ Array(access_config)
424
+ end
425
+
426
+ def instance_scheduling
427
+ Google::Apis::ComputeV1::Scheduling.new.tap do |scheduling|
428
+ scheduling.automatic_restart = auto_restart?.to_s
429
+ scheduling.preemptible = preemptible?.to_s
430
+ scheduling.on_host_maintenance = migrate_setting
431
+ end
432
+ end
433
+
434
+ def preemptible?
435
+ config[:preemptible]
436
+ end
437
+
438
+ def auto_migrate?
439
+ preemptible? ? false : config[:auto_migrate]
440
+ end
441
+
442
+ def auto_restart?
443
+ preemptible? ? false : config[:auto_restart]
444
+ end
445
+
446
+ def migrate_setting
447
+ auto_migrate? ? "MIGRATE" : "TERMINATE"
448
+ end
449
+
450
+ def instance_service_accounts
451
+ return if config[:service_account_scopes].nil? || config[:service_account_scopes].empty?
452
+
453
+ service_account = Google::Apis::ComputeV1::ServiceAccount.new
454
+ service_account.email = config[:service_account_name]
455
+ service_account.scopes = config[:service_account_scopes].map { |scope| service_account_scope_url(scope) }
456
+
457
+ Array(service_account)
458
+ end
459
+
460
+ def service_account_scope_url(scope)
461
+ return scope if scope.start_with?("https://www.googleapis.com/auth/")
462
+ "https://www.googleapis.com/auth/#{translate_scope_alias(scope)}"
463
+ end
464
+
465
+ def translate_scope_alias(scope_alias)
466
+ SCOPE_ALIAS_MAP.fetch(scope_alias, scope_alias)
467
+ end
468
+
469
+ def instance_tags
470
+ Google::Apis::ComputeV1::Tags.new.tap { |tag_obj| tag_obj.items = config[:tags] }
471
+ end
472
+
473
+ def wait_time
474
+ config[:wait_time]
475
+ end
476
+
477
+ def refresh_rate
478
+ config[:refresh_rate]
479
+ end
480
+
481
+ def wait_for_status(requested_status, &block)
482
+ last_status = ""
483
+
484
+ begin
485
+ Timeout.timeout(wait_time) do
486
+ loop do
487
+ item = yield
488
+ current_status = item.status
489
+
490
+ unless last_status == current_status
491
+ last_status = current_status
492
+ info("Current status: #{current_status}")
493
+ end
494
+
495
+ break if current_status == requested_status
496
+
497
+ sleep refresh_rate
498
+ end
499
+ end
500
+ rescue Timeout::Error
501
+ error("Request did not complete in #{wait_time} seconds. Check the Google Cloud Console for more info.")
502
+ raise
503
+ end
504
+ end
505
+
506
+ def wait_for_operation(operation)
507
+ operation_name = operation.name
508
+
509
+ wait_for_status("DONE") { zone_operation(operation_name) }
510
+
511
+ errors = operation_errors(operation_name)
512
+ return if errors.empty?
513
+
514
+ errors.each do |error|
515
+ error("#{error.code}: #{error.message}")
516
+ end
517
+
518
+ raise "Operation #{operation_name} failed."
519
+ end
520
+
521
+ def wait_for_server
522
+ begin
523
+ instance.transport.connection(state).wait_until_ready
524
+ rescue
525
+ error("Server not reachable. Destroying server...")
526
+ destroy(state)
527
+ raise
528
+ end
529
+ end
530
+
531
+ def zone_operation(operation_name)
532
+ connection.get_zone_operation(project, zone, operation_name)
533
+ end
534
+
535
+ def operation_errors(operation_name)
536
+ operation = zone_operation(operation_name)
537
+ return [] if operation.error.nil?
538
+
539
+ operation.error.errors
540
+ end
541
+ end
542
+ end
543
+ end