vagrant-google 2.3.0.rc0 → 2.6.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/.gitignore +3 -0
- data/.rubocop.yml +12 -4
- data/.ruby-version +2 -2
- data/CHANGELOG.md +72 -5
- data/Gemfile +2 -1
- data/README.md +43 -32
- data/lib/vagrant-google/action.rb +10 -0
- data/lib/vagrant-google/action/connect_google.rb +5 -5
- data/lib/vagrant-google/action/run_instance.rb +128 -76
- data/lib/vagrant-google/action/setup_winrm_password.rb +72 -0
- data/lib/vagrant-google/action/start_instance.rb +4 -4
- data/lib/vagrant-google/config.rb +117 -53
- data/lib/vagrant-google/version.rb +1 -1
- data/locales/en.yml +7 -10
- data/tasks/acceptance.rake +0 -5
- data/tasks/changelog.rake +40 -0
- data/test/acceptance/skeletons/image_family/Vagrantfile +2 -0
- data/test/unit/common/config_test.rb +131 -43
- data/vagrant-google.gemspec +4 -3
- data/vagrantfile_examples/Vagrantfile.multiple_machines +0 -3
- data/vagrantfile_examples/Vagrantfile.provision_single +0 -2
- data/vagrantfile_examples/Vagrantfile.simple +0 -1
- data/vagrantfile_examples/Vagrantfile.zone_config +0 -1
- metadata +27 -13
- data/.rubocop_todo.yml +0 -305
@@ -0,0 +1,72 @@
|
|
1
|
+
# Copyright 2015 Google Inc. All Rights Reserved.
|
2
|
+
#
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
4
|
+
# you may not use this file except in compliance with the License.
|
5
|
+
# You may obtain a copy of the License at
|
6
|
+
#
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
8
|
+
#
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12
|
+
# See the License for the specific language governing permissions and
|
13
|
+
# limitations under the License.
|
14
|
+
#
|
15
|
+
# Changes:
|
16
|
+
# April 2019: Modified example found here:
|
17
|
+
# https://github.com/GoogleCloudPlatform/compute-image-windows/blob/master/examples/windows_auth_python_sample.py
|
18
|
+
# to enable WinRM with vagrant.
|
19
|
+
|
20
|
+
module VagrantPlugins
|
21
|
+
module Google
|
22
|
+
module Action
|
23
|
+
# Sets up a temporary WinRM password using Google's method for
|
24
|
+
# establishing a new password over encrypted channels.
|
25
|
+
class SetupWinrmPassword
|
26
|
+
def initialize(app, env)
|
27
|
+
@app = app
|
28
|
+
@logger = Log4r::Logger.new("vagrant_google::action::setup_winrm_password")
|
29
|
+
end
|
30
|
+
|
31
|
+
def setup_password(env, instance, zone, user)
|
32
|
+
# Setup
|
33
|
+
compute = env[:google_compute]
|
34
|
+
server = compute.servers.get(instance, zone)
|
35
|
+
password = server.reset_windows_password(user)
|
36
|
+
|
37
|
+
env[:ui].info("Temp Password: #{password}")
|
38
|
+
|
39
|
+
password
|
40
|
+
end
|
41
|
+
|
42
|
+
def call(env)
|
43
|
+
# Get the configs
|
44
|
+
zone = env[:machine].provider_config.zone
|
45
|
+
zone_config = env[:machine].provider_config.get_zone_config(zone)
|
46
|
+
|
47
|
+
instance = zone_config.name
|
48
|
+
user = env[:machine].config.winrm.username
|
49
|
+
pass = env[:machine].config.winrm.password
|
50
|
+
|
51
|
+
# Get Temporary Password, set WinRM password
|
52
|
+
temp_pass = setup_password(env, instance, zone, user)
|
53
|
+
env[:machine].config.winrm.password = temp_pass
|
54
|
+
|
55
|
+
# Wait for WinRM To be Ready
|
56
|
+
env[:ui].info("Waiting for WinRM To be ready")
|
57
|
+
env[:machine].communicate.wait_for_ready(60)
|
58
|
+
|
59
|
+
# Use WinRM to Change Password to one in Vagrantfile
|
60
|
+
env[:ui].info("Changing password from temporary to winrm password")
|
61
|
+
winrmcomm = VagrantPlugins::CommunicatorWinRM::Communicator.new(env[:machine])
|
62
|
+
cmd = "net user #{user} #{pass}"
|
63
|
+
opts = { elevated: true }
|
64
|
+
winrmcomm.test(cmd, opts)
|
65
|
+
|
66
|
+
# Update WinRM password to reflect updated one
|
67
|
+
env[:machine].config.winrm.password = pass
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -65,9 +65,9 @@ module VagrantPlugins
|
|
65
65
|
@logger.info("Time to instance ready: #{env[:metrics]["instance_ready_time"]}")
|
66
66
|
|
67
67
|
unless env[:interrupted]
|
68
|
-
env[:metrics]["
|
69
|
-
# Wait for
|
70
|
-
env[:ui].info(I18n.t("vagrant_google.
|
68
|
+
env[:metrics]["instance_comm_time"] = Util::Timer.time do
|
69
|
+
# Wait for Comms to be ready.
|
70
|
+
env[:ui].info(I18n.t("vagrant_google.waiting_for_comm"))
|
71
71
|
while true
|
72
72
|
# If we're interrupted then just back out
|
73
73
|
break if env[:interrupted]
|
@@ -76,7 +76,7 @@ module VagrantPlugins
|
|
76
76
|
end
|
77
77
|
end
|
78
78
|
|
79
|
-
@logger.info("Time for
|
79
|
+
@logger.info("Time for Comms ready: #{env[:metrics]["instance_comm_time"]}")
|
80
80
|
|
81
81
|
# Ready and booted!
|
82
82
|
env[:ui].info(I18n.t("vagrant_google.ready"))
|
@@ -17,11 +17,6 @@ require "securerandom"
|
|
17
17
|
module VagrantPlugins
|
18
18
|
module Google
|
19
19
|
class Config < Vagrant.plugin("2", :config) # rubocop:disable Metrics/ClassLength
|
20
|
-
# The Service Account Client ID Email address
|
21
|
-
#
|
22
|
-
# @return [String]
|
23
|
-
attr_accessor :google_client_email
|
24
|
-
|
25
20
|
# The path to the Service Account json-formatted private key
|
26
21
|
#
|
27
22
|
# @return [String]
|
@@ -117,6 +112,11 @@ module VagrantPlugins
|
|
117
112
|
# @return String
|
118
113
|
attr_accessor :external_ip
|
119
114
|
|
115
|
+
# The network IP Address to use
|
116
|
+
#
|
117
|
+
# @return String
|
118
|
+
attr_accessor :network_ip
|
119
|
+
|
120
120
|
# Use private ip address
|
121
121
|
#
|
122
122
|
# @return Boolean
|
@@ -152,8 +152,8 @@ module VagrantPlugins
|
|
152
152
|
# @return [Int]
|
153
153
|
attr_accessor :instance_ready_timeout
|
154
154
|
|
155
|
-
# The zone to launch the instance into.
|
156
|
-
# use the default us-central1-f.
|
155
|
+
# The zone to launch the instance into.
|
156
|
+
# If nil and the "default" network is set use the default us-central1-f.
|
157
157
|
#
|
158
158
|
# @return [String]
|
159
159
|
attr_accessor :zone
|
@@ -178,38 +178,68 @@ module VagrantPlugins
|
|
178
178
|
# @return [Array<Hash>]
|
179
179
|
attr_accessor :additional_disks
|
180
180
|
|
181
|
+
# (Optional - Override default WinRM setup before for Public Windows images)
|
182
|
+
#
|
183
|
+
# @return [Boolean]
|
184
|
+
attr_accessor :setup_winrm_password
|
185
|
+
|
186
|
+
# Accelerators
|
187
|
+
#
|
188
|
+
# @return [Array<Hash>]
|
189
|
+
attr_accessor :accelerators
|
190
|
+
|
191
|
+
# whether the instance has Secure Boot enabled
|
192
|
+
#
|
193
|
+
# @return Boolean
|
194
|
+
attr_accessor :enable_secure_boot
|
195
|
+
|
196
|
+
# whether the instance has the vTPM enabled
|
197
|
+
#
|
198
|
+
# @return Boolean
|
199
|
+
attr_accessor :enable_vtpm
|
200
|
+
|
201
|
+
# whether the instance has integrity monitoring enabled
|
202
|
+
#
|
203
|
+
# @return Boolean
|
204
|
+
attr_accessor :enable_integrity_monitoring
|
205
|
+
|
181
206
|
def initialize(zone_specific=false)
|
182
|
-
@
|
183
|
-
@
|
184
|
-
@
|
185
|
-
@
|
186
|
-
@
|
187
|
-
@
|
188
|
-
@
|
189
|
-
@
|
190
|
-
@
|
191
|
-
@
|
192
|
-
@
|
193
|
-
@
|
194
|
-
@
|
195
|
-
@
|
196
|
-
@
|
197
|
-
@
|
198
|
-
@
|
199
|
-
@
|
200
|
-
@
|
201
|
-
@
|
202
|
-
@use_private_ip
|
203
|
-
@autodelete_disk
|
204
|
-
@preemptible
|
205
|
-
@auto_restart
|
206
|
-
@on_host_maintenance
|
207
|
-
@instance_ready_timeout
|
208
|
-
@zone
|
209
|
-
@scopes
|
210
|
-
@service_accounts
|
211
|
-
@service_account
|
212
|
-
@additional_disks
|
207
|
+
@google_json_key_location = UNSET_VALUE
|
208
|
+
@google_project_id = UNSET_VALUE
|
209
|
+
@image = UNSET_VALUE
|
210
|
+
@image_family = UNSET_VALUE
|
211
|
+
@image_project_id = UNSET_VALUE
|
212
|
+
@instance_group = UNSET_VALUE
|
213
|
+
@machine_type = UNSET_VALUE
|
214
|
+
@disk_size = UNSET_VALUE
|
215
|
+
@disk_name = UNSET_VALUE
|
216
|
+
@disk_type = UNSET_VALUE
|
217
|
+
@metadata = {}
|
218
|
+
@name = UNSET_VALUE
|
219
|
+
@network = UNSET_VALUE
|
220
|
+
@network_project_id = UNSET_VALUE
|
221
|
+
@subnetwork = UNSET_VALUE
|
222
|
+
@tags = []
|
223
|
+
@labels = {}
|
224
|
+
@can_ip_forward = UNSET_VALUE
|
225
|
+
@external_ip = UNSET_VALUE
|
226
|
+
@network_ip = UNSET_VALUE
|
227
|
+
@use_private_ip = UNSET_VALUE
|
228
|
+
@autodelete_disk = UNSET_VALUE
|
229
|
+
@preemptible = UNSET_VALUE
|
230
|
+
@auto_restart = UNSET_VALUE
|
231
|
+
@on_host_maintenance = UNSET_VALUE
|
232
|
+
@instance_ready_timeout = UNSET_VALUE
|
233
|
+
@zone = UNSET_VALUE
|
234
|
+
@scopes = UNSET_VALUE
|
235
|
+
@service_accounts = UNSET_VALUE
|
236
|
+
@service_account = UNSET_VALUE
|
237
|
+
@additional_disks = []
|
238
|
+
@setup_winrm_password = UNSET_VALUE
|
239
|
+
@accelerators = []
|
240
|
+
@enable_secure_boot = UNSET_VALUE
|
241
|
+
@enable_vtpm = UNSET_VALUE
|
242
|
+
@enable_integrity_monitoring = UNSET_VALUE
|
213
243
|
|
214
244
|
# Internal state (prefix with __ so they aren't automatically
|
215
245
|
# merged)
|
@@ -275,15 +305,24 @@ module VagrantPlugins
|
|
275
305
|
result.instance_variable_set(:@__zone_config, new_zone_config)
|
276
306
|
|
277
307
|
# Merge in the metadata
|
278
|
-
result.metadata.merge
|
279
|
-
|
308
|
+
result.metadata = self.metadata.merge(other.metadata)
|
309
|
+
|
310
|
+
# Merge in the labels
|
311
|
+
result.labels = self.labels.merge(other.labels)
|
312
|
+
|
313
|
+
# Merge in the tags
|
314
|
+
result.tags |= self.tags
|
315
|
+
result.tags |= other.tags
|
316
|
+
|
317
|
+
# Merge in the additional disks
|
318
|
+
result.additional_disks |= self.additional_disks
|
319
|
+
result.additional_disks |= other.additional_disks
|
280
320
|
end
|
281
321
|
end
|
282
322
|
|
283
323
|
def finalize! # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
284
324
|
# Try to get access keys from standard Google environment variables; they
|
285
325
|
# will default to nil if the environment variables are not present.
|
286
|
-
@google_client_email = ENV['GOOGLE_CLIENT_EMAIL'] if @google_client_email == UNSET_VALUE
|
287
326
|
@google_json_key_location = ENV['GOOGLE_JSON_KEY_LOCATION'] if @google_json_key_location == UNSET_VALUE
|
288
327
|
@google_project_id = ENV['GOOGLE_PROJECT_ID'] if @google_project_id == UNSET_VALUE
|
289
328
|
|
@@ -317,6 +356,7 @@ module VagrantPlugins
|
|
317
356
|
t = Time.now
|
318
357
|
@name = "i-#{t.strftime("%Y%m%d%H")}-" + SecureRandom.hex(4)
|
319
358
|
end
|
359
|
+
|
320
360
|
# Network defaults to 'default'
|
321
361
|
@network = "default" if @network == UNSET_VALUE
|
322
362
|
|
@@ -326,8 +366,13 @@ module VagrantPlugins
|
|
326
366
|
# Subnetwork defaults to nil
|
327
367
|
@subnetwork = nil if @subnetwork == UNSET_VALUE
|
328
368
|
|
329
|
-
# Default zone is us-central1-f
|
330
|
-
|
369
|
+
# Default zone is us-central1-f if using the default network
|
370
|
+
if @zone == UNSET_VALUE
|
371
|
+
@zone = nil
|
372
|
+
if @network == "default"
|
373
|
+
@zone = "us-central1-f"
|
374
|
+
end
|
375
|
+
end
|
331
376
|
|
332
377
|
# autodelete_disk defaults to true
|
333
378
|
@autodelete_disk = true if @autodelete_disk == UNSET_VALUE
|
@@ -338,6 +383,9 @@ module VagrantPlugins
|
|
338
383
|
# external_ip defaults to nil
|
339
384
|
@external_ip = nil if @external_ip == UNSET_VALUE
|
340
385
|
|
386
|
+
# network_ip defaults to nil
|
387
|
+
@network_ip = nil if @network_ip == UNSET_VALUE
|
388
|
+
|
341
389
|
# use_private_ip defaults to false
|
342
390
|
@use_private_ip = false if @use_private_ip == UNSET_VALUE
|
343
391
|
|
@@ -362,11 +410,23 @@ module VagrantPlugins
|
|
362
410
|
# Default IAM service account
|
363
411
|
@service_account = nil if @service_account == UNSET_VALUE
|
364
412
|
|
413
|
+
# Default Setup WinRM Password
|
414
|
+
@setup_winrm_password = nil if @setup_winrm_password == UNSET_VALUE
|
415
|
+
|
365
416
|
# Config option service_accounts is deprecated
|
366
417
|
if @service_accounts
|
367
418
|
@scopes = @service_accounts
|
368
419
|
end
|
369
420
|
|
421
|
+
# enable_secure_boot defaults to nil
|
422
|
+
@enable_secure_boot = false if @enable_secure_boot == UNSET_VALUE
|
423
|
+
|
424
|
+
# enable_vtpm defaults to nil
|
425
|
+
@enable_vtpm = false if @enable_vtpm == UNSET_VALUE
|
426
|
+
|
427
|
+
# enable_integrity_monitoring defaults to nil
|
428
|
+
@enable_integrity_monitoring = false if @enable_integrity_monitoring == UNSET_VALUE
|
429
|
+
|
370
430
|
# Compile our zone specific configurations only within
|
371
431
|
# NON-zone-SPECIFIC configurations.
|
372
432
|
unless @__zone_specific
|
@@ -403,13 +463,12 @@ module VagrantPlugins
|
|
403
463
|
# TODO: Check why provider-level settings are validated in the zone config
|
404
464
|
errors << I18n.t("vagrant_google.config.google_project_id_required") if \
|
405
465
|
config.google_project_id.nil?
|
406
|
-
|
407
|
-
|
408
|
-
|
409
|
-
|
410
|
-
|
411
|
-
|
412
|
-
File.exist?(File.expand_path(config.google_json_key_location.to_s, machine.env.root_path))
|
466
|
+
|
467
|
+
if config.google_json_key_location
|
468
|
+
errors << I18n.t("vagrant_google.config.private_key_missing") unless \
|
469
|
+
File.exist?(File.expand_path(config.google_json_key_location.to_s)) or
|
470
|
+
File.exist?(File.expand_path(config.google_json_key_location.to_s, machine.env.root_path))
|
471
|
+
end
|
413
472
|
|
414
473
|
if config.preemptible
|
415
474
|
errors << I18n.t("vagrant_google.config.auto_restart_invalid_on_preemptible") if \
|
@@ -422,10 +481,15 @@ module VagrantPlugins
|
|
422
481
|
errors << I18n.t("vagrant_google.config.image_and_image_family_set") if \
|
423
482
|
config.image
|
424
483
|
end
|
425
|
-
end
|
426
484
|
|
427
|
-
|
428
|
-
|
485
|
+
errors << I18n.t("vagrant_google.config.image_required") if config.image.nil? && config.image_family.nil?
|
486
|
+
errors << I18n.t("vagrant_google.config.name_required") if @name.nil?
|
487
|
+
|
488
|
+
if !config.accelerators.empty?
|
489
|
+
errors << I18n.t("vagrant_google.config.on_host_maintenance_invalid_with_accelerators") unless \
|
490
|
+
config.on_host_maintenance == "TERMINATE"
|
491
|
+
end
|
492
|
+
end
|
429
493
|
|
430
494
|
if @service_accounts
|
431
495
|
machine.env.ui.warn(I18n.t("vagrant_google.config.service_accounts_deprecaated"))
|
data/locales/en.yml
CHANGED
@@ -14,8 +14,8 @@ en:
|
|
14
14
|
Instance is not created. Please run `vagrant up` first.
|
15
15
|
ready: |-
|
16
16
|
Machine is booted and ready for use!
|
17
|
-
|
18
|
-
Machine is ready for
|
17
|
+
ready_comm: |-
|
18
|
+
Machine is ready for Communicator access!
|
19
19
|
rsync_not_found_warning: |-
|
20
20
|
Warning! Folder sync disabled because the rsync binary is missing.
|
21
21
|
Make sure rsync is installed and the binary can be found in the PATH.
|
@@ -31,8 +31,8 @@ en:
|
|
31
31
|
Waiting for GCP operation '%{name}' to finish...
|
32
32
|
waiting_for_ready: |-
|
33
33
|
Waiting for instance to become "ready"...
|
34
|
-
|
35
|
-
Waiting for
|
34
|
+
waiting_for_comm: |-
|
35
|
+
Waiting for Communicator to become available...
|
36
36
|
warn_networks: |-
|
37
37
|
Warning! The Google provider doesn't support any of the Vagrant
|
38
38
|
high-level network configurations (`config.vm.network`). They
|
@@ -52,9 +52,6 @@ en:
|
|
52
52
|
# Translations for config validation errors
|
53
53
|
#-------------------------------------------------------------------------------
|
54
54
|
config:
|
55
|
-
google_client_email_required: |-
|
56
|
-
A Google Service Account client email is required via
|
57
|
-
"google_client_email".
|
58
55
|
private_key_missing: |-
|
59
56
|
Private key for Google could not be found in the specified location.
|
60
57
|
zone_required: |-
|
@@ -63,9 +60,6 @@ en:
|
|
63
60
|
An instance name must be specified via "name" option.
|
64
61
|
image_required: |-
|
65
62
|
An image must be specified via "image" or "image_family" option.
|
66
|
-
google_key_location_required: |-
|
67
|
-
A private key pathname is required via:
|
68
|
-
"google_json_key_location" (for JSON keys)
|
69
63
|
google_project_id_required: |-
|
70
64
|
A Google Cloud Project ID is required via "google_project_id".
|
71
65
|
auto_restart_invalid_on_preemptible: |-
|
@@ -77,6 +71,9 @@ en:
|
|
77
71
|
"image" must be unset when setting "image_family"
|
78
72
|
service_accounts_deprecaated: |-
|
79
73
|
"service_accounts is deprecated. Please use scopes instead"
|
74
|
+
on_host_maintenance_invalid_with_accelerators: |-
|
75
|
+
"on_host_maintenance" option must be set to "TERMINATE" for instances
|
76
|
+
with accelerators
|
80
77
|
#-------------------------------------------------------------------------------
|
81
78
|
# Translations for exception classes
|
82
79
|
#-------------------------------------------------------------------------------
|
data/tasks/acceptance.rake
CHANGED
@@ -37,10 +37,6 @@ namespace :acceptance do
|
|
37
37
|
abort "Environment variable GOOGLE_PROJECT_ID is not set. Aborting."
|
38
38
|
end
|
39
39
|
|
40
|
-
unless ENV["GOOGLE_CLIENT_EMAIL"]
|
41
|
-
abort "Environment variable GOOGLE_CLIENT_EMAIL is not set. Aborting."
|
42
|
-
end
|
43
|
-
|
44
40
|
unless ENV["GOOGLE_SSH_USER"]
|
45
41
|
yellow "WARNING: GOOGLE_SSH_USER variable is not set. Will try to start tests using insecure Vagrant private key."
|
46
42
|
end
|
@@ -70,7 +66,6 @@ namespace :acceptance do
|
|
70
66
|
image_family
|
71
67
|
synced_folder/rsync
|
72
68
|
provisioner/shell
|
73
|
-
provisioner/chef-solo
|
74
69
|
).map{ |s| "provider/google/#{s}" }
|
75
70
|
|
76
71
|
command = "vagrant-spec test --components=#{components.join(" ")}"
|
@@ -0,0 +1,40 @@
|
|
1
|
+
require "fileutils"
|
2
|
+
|
3
|
+
# Helper method to insert text after a line that matches the regex
|
4
|
+
def insert_after_line(file, insert, regex = /^## Next/)
|
5
|
+
tempfile = File.open("#{file}.tmp", "w")
|
6
|
+
f = File.new(file)
|
7
|
+
f.each do |line|
|
8
|
+
tempfile << line
|
9
|
+
next unless line =~ regex
|
10
|
+
|
11
|
+
tempfile << "\n"
|
12
|
+
tempfile << insert
|
13
|
+
tempfile << "\n"
|
14
|
+
end
|
15
|
+
f.close
|
16
|
+
tempfile.close
|
17
|
+
|
18
|
+
FileUtils.mv("#{file}.tmp", file)
|
19
|
+
end
|
20
|
+
|
21
|
+
# Extracts all changes that have been made after the latest pushed tag
|
22
|
+
def changes_since_last_tag
|
23
|
+
`git --no-pager log $(git describe --tags --abbrev=0)..HEAD --grep="Merge" --pretty=format:"%t - %s%n%b%n"`
|
24
|
+
end
|
25
|
+
|
26
|
+
# Extracts all github users contributed since last tag
|
27
|
+
def users_since_last_tag
|
28
|
+
`git --no-pager log $(git describe --tags --abbrev=0)..HEAD --grep="Merge" --pretty=format:"%s" | cut -d' ' -f 6 | cut -d/ -f1 | sort | uniq`
|
29
|
+
end
|
30
|
+
|
31
|
+
namespace :changelog do
|
32
|
+
task :generate do
|
33
|
+
insert_after_line("CHANGELOG.md", changes_since_last_tag, /^## Next/)
|
34
|
+
printf("Users contributed since last release:\n")
|
35
|
+
contributors = users_since_last_tag.split("\n").map { |name| "@" + name }
|
36
|
+
printf("Huge thanks to all our contributors 🎆\n")
|
37
|
+
printf("Special thanks to: " + contributors.join(" ") + "\n")
|
38
|
+
printf("\nI'll merge this and release the gem once all tests pass.\n")
|
39
|
+
end
|
40
|
+
end
|