kitchen-google 2.8.0 → 3.0.1

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: d96f1f6b4c605799e8b3af57a4e8966a786f808f1c3e5e2c201d34e306da454d
4
- data.tar.gz: 2f8f62792d8c6b15a568dd5f8abdf177acafad3762df1e04ccacf4b69f502fe2
3
+ metadata.gz: 0b723b02e719da68e14ea28e557e7288ebf83e3f7770fe26b7a24c67b5edfa2a
4
+ data.tar.gz: 7fc550f1fa2ee2ef1803849c04ed561eeb8417ea34c7a9dec37a336d02223d90
5
5
  SHA512:
6
- metadata.gz: cb7e05264bf801fc495576aca5ebd797193ea5e6140702e26d4248a0a3c54892702422fef3a563b2e78882f92dc2abedbae99b00371afcefdadeecc4bafa27bc
7
- data.tar.gz: de05cdf3ae3b40aac2d40fb7fb3e3154dafb16d01979632a83ea7de5b4fdc249343c10d63407c0a36e09bd22ec945a829468a9d61f1caca58e52a2bfffa1fbfa
6
+ metadata.gz: 619b786dc4939d75e666676eace51b0b87623ee800333e9569bd3ccc31204773badc2d5bcb449e2e9852faec5dc1cce997fd7d83dae58c07bb2651f265769081
7
+ data.tar.gz: e260b90a3249fe4f5de2469e9777b0f6a6a3e8c21cb86989c8470ee575d17155b7505df325f792321f91d098549278cf368026ff6275dee369c56be26cfc904c
@@ -0,0 +1,288 @@
1
+ #
2
+ # Author:: Chef Partner Engineering (<partnereng@chef.io>)
3
+ #
4
+ # Copyright (C) 2016, Chef Software, Inc.
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ require "base64" unless defined?(Base64)
19
+ require "date" unless defined?(DateTime)
20
+ require "json" unless defined?(JSON)
21
+ require "openssl" unless defined?(OpenSSL)
22
+ require "timeout" unless defined?(Timeout)
23
+ require "google/apis/compute_v1"
24
+ require "kitchen"
25
+
26
+ module Kitchen
27
+ module Driver
28
+ class Gce < Kitchen::Driver::Base
29
+ # Resets the password of a Windows account on a GCE instance.
30
+ #
31
+ # Google does not expose Windows credentials through the API. Instead, an
32
+ # agent inside the guest watches the instance's `windows-keys` metadata
33
+ # for an RSA public key, resets the named account, and writes the new
34
+ # password back to the instance's serial port, encrypted with that key.
35
+ #
36
+ # This class performs the client half of that exchange: it publishes a
37
+ # freshly generated public key, watches the serial port for the matching
38
+ # response, and decrypts the password with the private key, which never
39
+ # leaves this process.
40
+ #
41
+ # It borrows the driver's authorised API client, project, zone and
42
+ # operation handling rather than establishing its own.
43
+ #
44
+ # @see https://cloud.google.com/compute/docs/instances/windows/automate-pw-generation
45
+ #
46
+ # @example
47
+ # WindowsPassword.new(driver,
48
+ # instance_name: "tk-win-1",
49
+ # email: "user@example.com",
50
+ # username: "Administrator").new_password #=> "hR2$k9..."
51
+ class WindowsPassword
52
+ # Seconds to wait for the in-guest agent when no timeout is configured.
53
+ #
54
+ # @return [Integer] the default timeout
55
+ DEFAULT_TIMEOUT = 120
56
+
57
+ # Serial port the Windows agent writes its response to.
58
+ #
59
+ # @return [Integer] the serial port number
60
+ SERIAL_PORT = 4
61
+
62
+ # Instance metadata key the agent watches for a public key.
63
+ #
64
+ # @return [String] the metadata key
65
+ METADATA_KEY = "windows-keys".freeze
66
+
67
+ # Size, in bits, of the RSA key generated for the exchange.
68
+ #
69
+ # @return [Integer] the key size
70
+ KEY_SIZE = 2048
71
+
72
+ # How long, in seconds, the published key remains valid.
73
+ #
74
+ # @return [Integer] the key lifetime
75
+ KEY_TTL = 300
76
+
77
+ # @return [Kitchen::Driver::Gce] the driver this request runs through
78
+ attr_reader :driver
79
+
80
+ # @return [String] the GCE instance whose password is being reset
81
+ attr_reader :instance_name
82
+
83
+ # @return [String] the email address of the GCE user making the request
84
+ attr_reader :email
85
+
86
+ # @return [String] the Windows account being reset
87
+ attr_reader :username
88
+
89
+ # @return [Integer] seconds to wait for the in-guest agent
90
+ attr_reader :timeout
91
+
92
+ # @param driver [Kitchen::Driver::Gce] driver supplying the API client,
93
+ # project, zone, operation handling and logging
94
+ # @param instance_name [String] the GCE instance to reset a password on
95
+ # @param email [String] email address of the GCE user making the request
96
+ # @param username [String, nil] the Windows account, defaulting to
97
+ # `Administrator`
98
+ # @param timeout [Integer, nil] seconds to wait for the in-guest agent,
99
+ # defaulting to {DEFAULT_TIMEOUT}
100
+ # @raise [ArgumentError] if the instance name or email is missing
101
+ def initialize(driver, instance_name:, email:, username: nil, timeout: nil)
102
+ raise ArgumentError, "Instance name not specified" if instance_name.nil?
103
+ raise ArgumentError, "Email address of GCE user not specified" if email.nil?
104
+
105
+ @driver = driver
106
+ @instance_name = instance_name
107
+ @email = email
108
+ @username = username || "Administrator"
109
+ @timeout = (timeout || DEFAULT_TIMEOUT).to_i
110
+ end
111
+
112
+ # Runs the full exchange and returns the new password.
113
+ #
114
+ # @return [String] the plaintext password the agent generated
115
+ # @raise [RuntimeError] if the instance is missing, or the agent reports
116
+ # that it could not reset the password
117
+ # @raise [Timeout::Error] if the agent does not respond within {#timeout}
118
+ def new_password
119
+ publish_public_key
120
+ decrypt(await_response)
121
+ end
122
+
123
+ # Replaces the instance's `windows-keys` metadata with our public key,
124
+ # leaving every other metadata entry untouched, and waits for the update
125
+ # to take effect.
126
+ #
127
+ # @return [void]
128
+ def publish_public_key
129
+ metadata = instance_metadata
130
+ items = Array(metadata.items).reject { |item| item.key == METADATA_KEY }
131
+ items << key_metadata_item
132
+ metadata.items = items
133
+
134
+ driver.debug("Publishing a Windows password key to #{instance_name} for #{username}")
135
+ driver.wait_for_operation(
136
+ driver.connection.set_instance_metadata(driver.project, driver.zone, instance_name, metadata)
137
+ )
138
+ end
139
+
140
+ # The instance's current metadata.
141
+ #
142
+ # @return [Google::Apis::ComputeV1::Metadata] the metadata
143
+ # @raise [RuntimeError] if the instance cannot be found
144
+ def instance_metadata
145
+ driver.server_instance(instance_name).metadata
146
+ rescue Google::Apis::ClientError
147
+ raise "Unable to locate instance #{instance_name} in project #{driver.project}, zone #{driver.zone}"
148
+ end
149
+
150
+ # The metadata entry describing the key exchange request.
151
+ #
152
+ # @return [Google::Apis::ComputeV1::Metadata::Item] the entry
153
+ def key_metadata_item
154
+ Google::Apis::ComputeV1::Metadata::Item.new(
155
+ key: METADATA_KEY,
156
+ value: key_request.to_json
157
+ )
158
+ end
159
+
160
+ # The payload the in-guest agent reads to perform the reset.
161
+ #
162
+ # @return [Hash] the request, in the shape the agent expects
163
+ def key_request
164
+ {
165
+ "userName" => username,
166
+ "modulus" => modulus,
167
+ "exponent" => exponent,
168
+ "email" => email,
169
+ "expireOn" => expiration,
170
+ }
171
+ end
172
+
173
+ # When the published key stops being valid.
174
+ #
175
+ # @return [String] an RFC 3339 timestamp
176
+ def expiration
177
+ (Time.now + KEY_TTL).to_datetime.rfc3339
178
+ end
179
+
180
+ # Polls the serial port until the agent answers our key.
181
+ #
182
+ # @return [Hash] the agent's response
183
+ # @raise [Timeout::Error] if the agent does not respond in time
184
+ def await_response
185
+ Timeout.timeout(timeout) do
186
+ loop do
187
+ response = response_from_serial_port
188
+ return response unless response.nil?
189
+
190
+ driver.debug("No password response yet for #{instance_name}, waiting...")
191
+ sleep driver.refresh_rate
192
+ end
193
+ end
194
+ rescue Timeout::Error
195
+ raise Timeout::Error, "Timed out after #{timeout} seconds waiting for the GCE agent " \
196
+ "to reset the password for #{username} on #{instance_name}"
197
+ end
198
+
199
+ # Scans the serial port output for a response matching our key.
200
+ #
201
+ # The port carries arbitrary boot logging, so every line that is not
202
+ # JSON, or is JSON but not an object, is skipped. The newest lines are
203
+ # examined first.
204
+ #
205
+ # @return [Hash, nil] the matching response, or nil if none is present
206
+ def response_from_serial_port
207
+ serial_port_output.to_s.lines.reverse_each do |line|
208
+ event = parse_event(line)
209
+ next if event.nil?
210
+
211
+ return event if event["modulus"] == modulus && event["exponent"] == exponent
212
+ end
213
+
214
+ nil
215
+ end
216
+
217
+ # The current contents of the instance's serial port.
218
+ #
219
+ # @return [String, nil] the serial port output
220
+ def serial_port_output
221
+ driver.connection.get_instance_serial_port_output(
222
+ driver.project, driver.zone, instance_name, port: SERIAL_PORT
223
+ ).contents
224
+ end
225
+
226
+ # Parses one line of serial port output.
227
+ #
228
+ # @param line [String] the line to parse
229
+ # @return [Hash, nil] the parsed object, or nil if the line is not a
230
+ # JSON object
231
+ def parse_event(line)
232
+ event = JSON.parse(line.strip)
233
+ event.is_a?(Hash) ? event : nil
234
+ rescue JSON::ParserError
235
+ nil
236
+ end
237
+
238
+ # Decrypts the password from the agent's response.
239
+ #
240
+ # OpenSSL hands back binary-tagged bytes. The agent sends UTF-8, so the
241
+ # result is retagged rather than left as ASCII-8BIT, which would other-
242
+ # wise be written into the state file as a binary blob.
243
+ #
244
+ # @param response [Hash] the agent's response
245
+ # @return [String] the plaintext password, encoded as UTF-8
246
+ # @raise [RuntimeError] if the agent reported a failed reset
247
+ def decrypt(response)
248
+ unless response["passwordFound"]
249
+ raise "The GCE agent could not reset the password for #{username} on #{instance_name}"
250
+ end
251
+
252
+ private_key.private_decrypt(
253
+ Base64.strict_decode64(response["encryptedPassword"]),
254
+ OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING
255
+ ).force_encoding(Encoding::UTF_8)
256
+ end
257
+
258
+ # The private key for this exchange, which never leaves the process.
259
+ #
260
+ # @return [OpenSSL::PKey::RSA] the key pair
261
+ def private_key
262
+ @private_key ||= OpenSSL::PKey::RSA.new(KEY_SIZE)
263
+ end
264
+
265
+ # The public half of {#private_key}.
266
+ #
267
+ # @return [OpenSSL::PKey::RSA] the public key
268
+ def public_key
269
+ private_key.public_key
270
+ end
271
+
272
+ # The public key's modulus, as the agent expects it.
273
+ #
274
+ # @return [String] the Base64-encoded big-endian modulus
275
+ def modulus
276
+ @modulus ||= Base64.strict_encode64(public_key.n.to_s(2))
277
+ end
278
+
279
+ # The public key's exponent, as the agent expects it.
280
+ #
281
+ # @return [String] the Base64-encoded big-endian exponent
282
+ def exponent
283
+ @exponent ||= Base64.strict_encode64(public_key.e.to_s(2))
284
+ end
285
+ end
286
+ end
287
+ end
288
+ end
@@ -16,10 +16,10 @@
16
16
  # See the License for the specific language governing permissions and
17
17
  # limitations under the License.
18
18
 
19
- require "gcewinpass"
20
19
  require "google/apis/compute_v1"
21
20
  require "kitchen"
22
21
  require_relative "gce_version"
22
+ require_relative "gce/windows_password"
23
23
  require "securerandom" unless defined?(SecureRandom)
24
24
  require "timeout" unless defined?(Timeout)
25
25
 
@@ -125,11 +125,17 @@ module Kitchen
125
125
 
126
126
  # Configuration applied to every disk before the user's own settings.
127
127
  #
128
+ # Deliberately sets no `disk_type`. GCE derives an omitted disk type from
129
+ # the instance's machine series -- pd-standard on first- and
130
+ # second-generation series such as N1 and N2, pd-balanced on C3, C3D and
131
+ # M3, and hyperdisk-balanced on C4, N4 and newer -- so leaving it unset is
132
+ # the only default that is compatible with every machine type. Naming one
133
+ # here would fail outright on the families that no longer accept it.
134
+ #
128
135
  # @return [Hash] the per-disk defaults
129
136
  DISK_DEFAULT_CONFIG = {
130
137
  autodelete_disk: true,
131
138
  disk_size: 10,
132
- disk_type: "pd-standard",
133
139
  }.freeze
134
140
 
135
141
  # Human-readable driver name shown in Test Kitchen output.
@@ -269,9 +275,12 @@ module Kitchen
269
275
  boot: true,
270
276
  autodelete_disk: config.fetch(:autodelete_disk, DISK_DEFAULT_CONFIG[:autodelete_disk]),
271
277
  disk_size: config.fetch(:disk_size, DISK_DEFAULT_CONFIG[:disk_size]),
272
- disk_type: config.fetch(:disk_type, DISK_DEFAULT_CONFIG[:disk_type]),
273
278
  }
274
279
 
280
+ # Carry the key only when the user set it, so that an unset type stays
281
+ # absent rather than becoming an explicit nil. See DISK_DEFAULT_CONFIG.
282
+ disk_config[:disk_type] = config[:disk_type] if config[:disk_type]
283
+
275
284
  raise "Disk type #{disk_config[:disk_type]} is not valid" unless valid_disk_type?(disk_config[:disk_type])
276
285
 
277
286
  disk_config
@@ -437,6 +446,8 @@ module Kitchen
437
446
  #
438
447
  # @param server_name [String] the GCE instance name
439
448
  # @return [void]
449
+ # @raise [RuntimeError] if the in-guest agent could not reset the password
450
+ # @raise [Timeout::Error] if the agent does not respond in time
440
451
  def update_windows_password(server_name)
441
452
  return unless winrm_transport?
442
453
 
@@ -444,15 +455,13 @@ module Kitchen
444
455
 
445
456
  info("Resetting the Windows password for user #{username} on #{server_name}...")
446
457
 
447
- opts = {
448
- project: project,
449
- zone: zone,
458
+ state[:password] = WindowsPassword.new(
459
+ self,
450
460
  instance_name: server_name,
451
461
  email: config[:email],
452
462
  username: username,
453
- }
454
- opts[:timeout] = config[:winpass_timeout] unless config[:winpass_timeout].nil?
455
- state[:password] = GoogleComputeWindowsPassword.new(**opts).new_password
463
+ timeout: config[:winpass_timeout]
464
+ ).new_password
456
465
 
457
466
  info("Password reset complete on #{server_name}.")
458
467
  end
@@ -525,10 +534,13 @@ module Kitchen
525
534
 
526
535
  # Whether a disk type exists in the target zone.
527
536
  #
537
+ # An unset type is valid: the driver sends no `diskType` at all and GCE
538
+ # substitutes the default for the instance's machine series.
539
+ #
528
540
  # @param disk_type [String, nil] the disk type to check
529
- # @return [Boolean] true if the disk type is valid
541
+ # @return [Boolean] true if the disk type is valid or unset
530
542
  def valid_disk_type?(disk_type)
531
- return false if disk_type.nil?
543
+ return true if disk_type.nil?
532
544
 
533
545
  check_api_call { connection.get_disk_type(project, zone, disk_type) }
534
546
  end
@@ -753,7 +765,7 @@ module Kitchen
753
765
  disk.boot = true if disk_config[:boot]
754
766
  disk.auto_delete = disk_config[:autodelete_disk]
755
767
  params.disk_size_gb = disk_config[:disk_size]
756
- params.disk_type = disk_type_url_for(disk_config[:disk_type])
768
+ params.disk_type = disk_type_url_for(disk_config[:disk_type]) if disk_config[:disk_type]
757
769
 
758
770
  if local_ssd?(disk_config)
759
771
  info("Creating a #{LOCAL_SSD_SIZE_GB} GB local ssd as scratch disk (https://cloud.google.com/compute/docs/disks/#localssds).")
@@ -781,7 +793,7 @@ module Kitchen
781
793
  disk = Google::Apis::ComputeV1::Disk.new
782
794
  disk.name = unique_disk_name
783
795
  disk.size_gb = disk_config[:disk_size]
784
- disk.type = disk_type_url_for(disk_config[:disk_type])
796
+ disk.type = disk_type_url_for(disk_config[:disk_type]) if disk_config[:disk_type]
785
797
 
786
798
  info("Creating a #{disk_config[:disk_size]} GB disk named #{unique_disk_name}...")
787
799
  wait_for_operation(connection.insert_disk(project, zone, disk))
@@ -28,6 +28,6 @@ module Kitchen
28
28
  # release. Keep it on one line.
29
29
  #
30
30
  # @return [String] the gem version
31
- GCE_VERSION = "2.8.0"
31
+ GCE_VERSION = "3.0.1"
32
32
  end
33
33
  end
metadata CHANGED
@@ -1,63 +1,119 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kitchen-google
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.8.0
4
+ version: 3.0.1
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-08-22 00:00:00.000000000 Z
11
+ date: 2026-08-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: gcewinpass
14
+ name: google-apis-compute_v1
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - "~>"
17
+ - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '1.1'
19
+ version: '0.75'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
- - - "~>"
24
+ - - ">="
25
25
  - !ruby/object:Gem::Version
26
- version: '1.1'
26
+ version: '0.75'
27
27
  - !ruby/object:Gem::Dependency
28
- name: google-apis-compute_v1
28
+ name: test-kitchen
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - ">="
32
32
  - !ruby/object:Gem::Version
33
- version: '0.75'
33
+ version: '3.0'
34
+ - - "<"
35
+ - !ruby/object:Gem::Version
36
+ version: '5.0'
34
37
  type: :runtime
35
38
  prerelease: false
36
39
  version_requirements: !ruby/object:Gem::Requirement
37
40
  requirements:
38
41
  - - ">="
39
42
  - !ruby/object:Gem::Version
40
- version: '0.75'
43
+ version: '3.0'
44
+ - - "<"
45
+ - !ruby/object:Gem::Version
46
+ version: '5.0'
41
47
  - !ruby/object:Gem::Dependency
42
- name: test-kitchen
48
+ name: base64
43
49
  requirement: !ruby/object:Gem::Requirement
44
50
  requirements:
45
51
  - - ">="
46
52
  - !ruby/object:Gem::Version
47
- version: 1.0.0
48
- - - "<"
53
+ version: '0.1'
54
+ type: :runtime
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
49
59
  - !ruby/object:Gem::Version
50
- version: '5.0'
60
+ version: '0.1'
61
+ - !ruby/object:Gem::Dependency
62
+ name: date
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '3.2'
51
68
  type: :runtime
52
69
  prerelease: false
53
70
  version_requirements: !ruby/object:Gem::Requirement
54
71
  requirements:
55
72
  - - ">="
56
73
  - !ruby/object:Gem::Version
57
- version: 1.0.0
58
- - - "<"
74
+ version: '3.2'
75
+ - !ruby/object:Gem::Dependency
76
+ name: json
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
59
80
  - !ruby/object:Gem::Version
60
- version: '5.0'
81
+ version: '2.5'
82
+ type: :runtime
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '2.5'
89
+ - !ruby/object:Gem::Dependency
90
+ name: securerandom
91
+ requirement: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0.1'
96
+ type: :runtime
97
+ prerelease: false
98
+ version_requirements: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0.1'
103
+ - !ruby/object:Gem::Dependency
104
+ name: timeout
105
+ requirement: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0.2'
110
+ type: :runtime
111
+ prerelease: false
112
+ version_requirements: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0.2'
61
117
  description: A Test-Kitchen driver for Google Compute Engine
62
118
  email:
63
119
  - help@sous-chefs.org
@@ -67,6 +123,7 @@ extra_rdoc_files: []
67
123
  files:
68
124
  - LICENSE
69
125
  - lib/kitchen/driver/gce.rb
126
+ - lib/kitchen/driver/gce/windows_password.rb
70
127
  - lib/kitchen/driver/gce_version.rb
71
128
  homepage: https://github.com/test-kitchen/kitchen-google
72
129
  licenses: