kitchen-google-as 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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