kitchen-google 2.8.0 → 3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d96f1f6b4c605799e8b3af57a4e8966a786f808f1c3e5e2c201d34e306da454d
4
- data.tar.gz: 2f8f62792d8c6b15a568dd5f8abdf177acafad3762df1e04ccacf4b69f502fe2
3
+ metadata.gz: 2c6f8ac82e7287b315c4983229aad826ffc6dc2616d7b94c0f1887c3f3fbfdd4
4
+ data.tar.gz: 15e6f00245977ef5f27fa4c9fb09ee81e320534e2b2757ab6f812cb43f2d5e03
5
5
  SHA512:
6
- metadata.gz: cb7e05264bf801fc495576aca5ebd797193ea5e6140702e26d4248a0a3c54892702422fef3a563b2e78882f92dc2abedbae99b00371afcefdadeecc4bafa27bc
7
- data.tar.gz: de05cdf3ae3b40aac2d40fb7fb3e3154dafb16d01979632a83ea7de5b4fdc249343c10d63407c0a36e09bd22ec945a829468a9d61f1caca58e52a2bfffa1fbfa
6
+ metadata.gz: 813a82a35180d748b4d629b71e9ff9204a31a4482443e70e49830a1457ff545d7b7542ecfea6644991a864a5052f3cf3ef905276cfd171e0248b62327072d6f0
7
+ data.tar.gz: ddbdb11f5bf6420395f772189ce8145f65120c01aee09a065702c735fae641f75a809875713dd6a525eaf0d1ecb53724d5fe5fe48fc246ca21d9268393f0235f
@@ -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
 
@@ -437,6 +437,8 @@ module Kitchen
437
437
  #
438
438
  # @param server_name [String] the GCE instance name
439
439
  # @return [void]
440
+ # @raise [RuntimeError] if the in-guest agent could not reset the password
441
+ # @raise [Timeout::Error] if the agent does not respond in time
440
442
  def update_windows_password(server_name)
441
443
  return unless winrm_transport?
442
444
 
@@ -444,15 +446,13 @@ module Kitchen
444
446
 
445
447
  info("Resetting the Windows password for user #{username} on #{server_name}...")
446
448
 
447
- opts = {
448
- project: project,
449
- zone: zone,
449
+ state[:password] = WindowsPassword.new(
450
+ self,
450
451
  instance_name: server_name,
451
452
  email: config[:email],
452
453
  username: username,
453
- }
454
- opts[:timeout] = config[:winpass_timeout] unless config[:winpass_timeout].nil?
455
- state[:password] = GoogleComputeWindowsPassword.new(**opts).new_password
454
+ timeout: config[:winpass_timeout]
455
+ ).new_password
456
456
 
457
457
  info("Password reset complete on #{server_name}.")
458
458
  end
@@ -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.0"
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.0
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: