knife-azure 3.0.6 → 5.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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/lib/azure/azure_interface.rb +1 -1
  3. data/lib/azure/custom_errors.rb +2 -2
  4. data/lib/azure/helpers.rb +1 -1
  5. data/lib/azure/resource_management/ARM_deployment_template.rb +82 -27
  6. data/lib/azure/resource_management/ARM_interface.rb +112 -118
  7. data/lib/azure/resource_management/rest/arm_client.rb +208 -0
  8. data/lib/azure/resource_management/rest/environments.rb +89 -0
  9. data/lib/azure/resource_management/rest/errors.rb +57 -0
  10. data/lib/azure/resource_management/rest/http.rb +335 -0
  11. data/lib/azure/resource_management/rest/rest_object.rb +103 -0
  12. data/lib/azure/resource_management/rest/secure_connection.rb +57 -0
  13. data/lib/azure/resource_management/rest/token_provider.rb +125 -0
  14. data/lib/azure/resource_management/vnet_config.rb +4 -4
  15. data/lib/azure/resource_management/windows_credentials.rb +3 -3
  16. data/lib/chef/knife/azurerm_server_create.rb +22 -10
  17. data/lib/chef/knife/azurerm_server_delete.rb +1 -1
  18. data/lib/chef/knife/azurerm_server_list.rb +1 -1
  19. data/lib/chef/knife/azurerm_server_show.rb +1 -1
  20. data/lib/chef/knife/bootstrap/bootstrapper.rb +142 -14
  21. data/lib/chef/knife/bootstrap/common_bootstrap_options.rb +4 -6
  22. data/lib/chef/knife/bootstrap_azurerm.rb +1 -1
  23. data/lib/chef/knife/helpers/azurerm_base.rb +170 -9
  24. data/lib/knife-azure/version.rb +2 -2
  25. metadata +37 -95
  26. data/lib/azure/service_management/ASM_interface.rb +0 -310
  27. data/lib/azure/service_management/ag.rb +0 -99
  28. data/lib/azure/service_management/certificate.rb +0 -235
  29. data/lib/azure/service_management/connection.rb +0 -102
  30. data/lib/azure/service_management/deploy.rb +0 -221
  31. data/lib/azure/service_management/disk.rb +0 -68
  32. data/lib/azure/service_management/host.rb +0 -184
  33. data/lib/azure/service_management/image.rb +0 -94
  34. data/lib/azure/service_management/loadbalancer.rb +0 -78
  35. data/lib/azure/service_management/rest.rb +0 -126
  36. data/lib/azure/service_management/role.rb +0 -717
  37. data/lib/azure/service_management/storageaccount.rb +0 -127
  38. data/lib/azure/service_management/utility.rb +0 -40
  39. data/lib/azure/service_management/vnet.rb +0 -134
  40. data/lib/chef/knife/azure_ag_create.rb +0 -73
  41. data/lib/chef/knife/azure_ag_list.rb +0 -35
  42. data/lib/chef/knife/azure_image_list.rb +0 -56
  43. data/lib/chef/knife/azure_internal-lb_create.rb +0 -74
  44. data/lib/chef/knife/azure_internal-lb_list.rb +0 -35
  45. data/lib/chef/knife/azure_server_create.rb +0 -531
  46. data/lib/chef/knife/azure_server_delete.rb +0 -136
  47. data/lib/chef/knife/azure_server_list.rb +0 -38
  48. data/lib/chef/knife/azure_server_show.rb +0 -41
  49. data/lib/chef/knife/azure_vnet_create.rb +0 -74
  50. data/lib/chef/knife/azure_vnet_list.rb +0 -35
  51. data/lib/chef/knife/bootstrap_azure.rb +0 -191
  52. data/lib/chef/knife/helpers/azure_base.rb +0 -392
@@ -0,0 +1,57 @@
1
+ #
2
+ # Copyright:: Copyright (c) 2012-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved.
3
+ # License:: Apache License, Version 2.0
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+
18
+ module Azure
19
+ class ResourceManagement
20
+ module Rest
21
+ # Raised for network/transport failures and throttling (HTTP 429/5xx).
22
+ # These are considered retryable by the HTTP layer. When Azure supplies a
23
+ # Retry-After hint, it is carried here so the caller can honour it.
24
+ class TransientError < StandardError
25
+ attr_reader :retry_after
26
+
27
+ def initialize(message = nil, retry_after: nil)
28
+ @retry_after = retry_after
29
+ super(message)
30
+ end
31
+ end
32
+
33
+ # Raised when Azure Resource Manager returns an API error (HTTP 4xx, or a
34
+ # failed long-running operation). This is the direct replacement for the
35
+ # retired SDK's MsRestAzure2::AzureOperationError and preserves the same
36
+ # accessors that consuming code relied on: #body returns the raw response
37
+ # body string, and #response.body returns the same string.
38
+ class OperationError < StandardError
39
+ # Minimal stand-in for the SDK response object; only #body is consumed.
40
+ ResponseBody = Struct.new(:body)
41
+
42
+ attr_reader :body, :code, :http_status
43
+
44
+ def initialize(message, body: nil, code: nil, http_status: nil)
45
+ @body = body
46
+ @code = code
47
+ @http_status = http_status
48
+ super(message)
49
+ end
50
+
51
+ def response
52
+ ResponseBody.new(@body)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,335 @@
1
+ #
2
+ # Copyright:: Copyright (c) 2012-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved.
3
+ # License:: Apache License, Version 2.0
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+
18
+ require "net/http" unless defined?(Net::HTTP)
19
+ require "uri" unless defined?(URI)
20
+ require "json" unless defined?(JSON)
21
+ require "time" unless defined?(Time.httpdate)
22
+ require_relative "errors"
23
+ require_relative "secure_connection"
24
+ require_relative "environments"
25
+
26
+ module Azure
27
+ class ResourceManagement
28
+ module Rest
29
+ # Thin Net::HTTP wrapper for talking to Azure Resource Manager. It:
30
+ #
31
+ # * separates transient/transport failures (retried) from Azure API
32
+ # errors (surfaced immediately as OperationError), instead of the
33
+ # retired SDK's behaviour of retrying every failure five times;
34
+ # * follows nextLink paging for list operations;
35
+ # * polls long-running operations (LRO) to a terminal state.
36
+ class Http
37
+ # Simple value object for a completed HTTP response.
38
+ Response = Struct.new(:status, :headers, :body)
39
+
40
+ TRANSIENT_STATUS = [429, 500, 502, 503, 504].freeze
41
+ DEFAULT_MAX_RETRIES = 3
42
+ DEFAULT_RETRY_INTERVAL = 5 # seconds
43
+ # Hard caps so a stuck long-running operation cannot poll forever.
44
+ DEFAULT_LRO_TIMEOUT = 30 * 60 # seconds
45
+ DEFAULT_MAX_POLLS = 360
46
+
47
+ def initialize(token_provider, max_retries: DEFAULT_MAX_RETRIES, retry_interval: DEFAULT_RETRY_INTERVAL,
48
+ lro_timeout: DEFAULT_LRO_TIMEOUT, max_polls: DEFAULT_MAX_POLLS,
49
+ open_timeout: SecureConnection::DEFAULT_OPEN_TIMEOUT,
50
+ read_timeout: SecureConnection::DEFAULT_READ_TIMEOUT,
51
+ environment: Environments.default)
52
+ @token_provider = token_provider
53
+ @max_retries = max_retries
54
+ @retry_interval = retry_interval
55
+ @lro_timeout = lro_timeout
56
+ @max_polls = max_polls
57
+ @open_timeout = open_timeout
58
+ @read_timeout = read_timeout
59
+ # nextLink, Location and Azure-AsyncOperation URLs are followed from
60
+ # values Azure itself returns and are requested with the same bearer
61
+ # token as the original call. Pin them to the configured ARM host so
62
+ # a malformed/compromised follow-up URL can never exfiltrate the
63
+ # token to an unrelated HTTPS host.
64
+ @allowed_host = URI.parse(environment.resource_manager_endpoint_url).host
65
+ end
66
+
67
+ def get(url)
68
+ request(:get, url)
69
+ end
70
+
71
+ def head(url)
72
+ request(:head, url)
73
+ end
74
+
75
+ def put(url, body)
76
+ request(:put, url, body)
77
+ end
78
+
79
+ def post(url, body)
80
+ request(:post, url, body)
81
+ end
82
+
83
+ def delete(url)
84
+ request(:delete, url)
85
+ end
86
+
87
+ # Issues a GET and returns the full list of items. Handles both ARM
88
+ # list shapes: most endpoints return a paged { "value": [...],
89
+ # "nextLink": ... } envelope, but a few (e.g. VM extension image
90
+ # versions) return a bare JSON array. nextLink is followed for the
91
+ # paged shape.
92
+ def get_all(url)
93
+ items = []
94
+ next_url = url
95
+ until next_url.nil?
96
+ body = get(next_url).body
97
+ if body.is_a?(Array)
98
+ items.concat(body)
99
+ next_url = nil
100
+ else
101
+ body ||= {}
102
+ items.concat(Array(body["value"]))
103
+ next_url = body["nextLink"]
104
+ end
105
+ end
106
+ items
107
+ end
108
+
109
+ # Performs an LRO request (PUT/DELETE) and polls the async operation to
110
+ # completion. Returns the final resource body (for a PUT) or nil.
111
+ def request_and_poll(method, url, body = nil)
112
+ response = request(method, url, body)
113
+ poll_until_complete(response)
114
+ end
115
+
116
+ private
117
+
118
+ def request(method, url, body = nil)
119
+ attempts = 0
120
+ begin
121
+ do_request(method, url, body)
122
+ rescue TransientError => e
123
+ attempts += 1
124
+ raise if attempts > @max_retries
125
+
126
+ # Honour the server's Retry-After hint (throttling) when present.
127
+ sleep(e.retry_after || @retry_interval)
128
+ retry
129
+ end
130
+ end
131
+
132
+ def do_request(method, url, body)
133
+ uri = URI.parse(url)
134
+ validate_uri!(uri, url)
135
+
136
+ http = SecureConnection.build(uri, open_timeout: @open_timeout, read_timeout: @read_timeout)
137
+
138
+ request = build_request(method, uri, body)
139
+
140
+ begin
141
+ raw = http.request(request)
142
+ rescue Timeout::Error, SocketError, Errno::ECONNRESET, Errno::ECONNREFUSED, IOError => e
143
+ raise TransientError, "Transport error talking to Azure: #{e.message}"
144
+ end
145
+
146
+ handle_response(raw)
147
+ end
148
+
149
+ # ARM-supplied follow-up URLs (nextLink, Location, Azure-AsyncOperation)
150
+ # are requested with the same bearer token as the original call, so a
151
+ # malformed or compromised monitor URL must never be allowed to
152
+ # downgrade the connection to plaintext HTTP, nor redirect the token to
153
+ # a host outside the configured ARM cloud.
154
+ def validate_uri!(uri, url)
155
+ unless uri.scheme == "https"
156
+ raise ArgumentError, "Refusing to send an Azure request to a non-HTTPS URL: #{url}"
157
+ end
158
+
159
+ unless uri.host && uri.host.casecmp?(@allowed_host)
160
+ raise ArgumentError,
161
+ "Refusing to send an Azure request (with the bearer token) to untrusted host " \
162
+ "'#{uri.host}'; expected '#{@allowed_host}': #{url}"
163
+ end
164
+ end
165
+
166
+ def build_request(method, uri, body)
167
+ klass = {
168
+ get: Net::HTTP::Get,
169
+ head: Net::HTTP::Head,
170
+ put: Net::HTTP::Put,
171
+ post: Net::HTTP::Post,
172
+ delete: Net::HTTP::Delete,
173
+ }[method]
174
+
175
+ path = uri.path
176
+ path += "?#{uri.query}" if uri.query
177
+ request = klass.new(path)
178
+ request["Authorization"] = @token_provider.authorization_header
179
+ request["Content-Type"] = "application/json"
180
+ request["Accept"] = "application/json"
181
+ unless body.nil?
182
+ request.body = body.is_a?(String) ? body : JSON.generate(body)
183
+ end
184
+ request
185
+ end
186
+
187
+ def handle_response(raw)
188
+ status = raw.code.to_i
189
+ headers = normalize_headers(raw)
190
+ parsed = parse_body(raw.body)
191
+
192
+ if TRANSIENT_STATUS.include?(status)
193
+ raise TransientError.new(
194
+ "Azure returned a transient error (HTTP #{status}).",
195
+ retry_after: parse_retry_after(headers["retry-after"])
196
+ )
197
+ elsif status >= 400
198
+ raise OperationError.new(
199
+ error_message(parsed, status),
200
+ body: raw.body,
201
+ code: error_code(parsed),
202
+ http_status: status
203
+ )
204
+ end
205
+
206
+ Response.new(status, headers, parsed)
207
+ end
208
+
209
+ def parse_body(raw_body)
210
+ return nil if raw_body.nil? || raw_body.strip.empty?
211
+
212
+ JSON.parse(raw_body)
213
+ rescue JSON::ParserError
214
+ nil
215
+ end
216
+
217
+ def normalize_headers(raw)
218
+ headers = {}
219
+ raw.each_header { |k, v| headers[k.downcase] = v }
220
+ headers
221
+ end
222
+
223
+ # Azure Resource Manager errors are nested ({ "error": { "code",
224
+ # "message" } }); the AAD/OAuth token endpoint returns a flat shape
225
+ # ({ "error": "invalid_client", "error_description": "..." }). Both are
226
+ # handled here so a meaningful message/code is surfaced either way.
227
+ def error_code(parsed)
228
+ return nil unless parsed.is_a?(Hash)
229
+
230
+ err = parsed["error"]
231
+ err.is_a?(Hash) ? err["code"] : err
232
+ end
233
+
234
+ def error_message(parsed, status)
235
+ fallback = "Azure API error (HTTP #{status})."
236
+ return fallback unless parsed.is_a?(Hash)
237
+
238
+ err = parsed["error"]
239
+ if err.is_a?(Hash)
240
+ err["message"] || fallback
241
+ elsif err.is_a?(String)
242
+ parsed["error_description"] ? "#{err}: #{parsed["error_description"]}" : err
243
+ else
244
+ fallback
245
+ end
246
+ end
247
+
248
+ # Parse a Retry-After header value (delta-seconds or an HTTP-date) into
249
+ # a number of seconds, or nil when absent/unparseable.
250
+ def parse_retry_after(value)
251
+ return nil if value.nil? || value.to_s.strip.empty?
252
+
253
+ return value.to_i if value.to_s =~ /\A\d+\z/
254
+
255
+ begin
256
+ seconds = (Time.httpdate(value) - Time.now).round
257
+ seconds > 0 ? seconds : 0
258
+ rescue ArgumentError
259
+ nil
260
+ end
261
+ end
262
+
263
+ # Poll a long-running operation to a terminal state, honouring
264
+ # Retry-After. Azure exposes two distinct monitor styles that must be
265
+ # handled differently:
266
+ #
267
+ # * Azure-AsyncOperation: a status monitor whose body carries a
268
+ # "status" field ("InProgress"/"Succeeded"/"Failed"/"Canceled").
269
+ # Terminal state comes from that field, not the HTTP code.
270
+ # * Location: a resource monitor that returns 202 while the operation
271
+ # is still running and a non-202 (200/204, often with no status
272
+ # body) once it has completed. Terminal success is signalled by the
273
+ # HTTP code, so a bodyless 200/204 here means "done", not "keep
274
+ # polling". Azure-AsyncOperation takes precedence when both exist.
275
+ def poll_until_complete(response)
276
+ async_op_url = response.headers["azure-asyncoperation"]
277
+ location_url = response.headers["location"]
278
+ return response.body if async_op_url.nil? && location_url.nil?
279
+
280
+ deadline = Time.now + @lro_timeout
281
+ polls = 0
282
+ loop do
283
+ polls += 1
284
+ if polls > @max_polls || Time.now >= deadline
285
+ raise OperationError.new(
286
+ "Timed out waiting for the long-running operation to complete " \
287
+ "(after #{polls - 1} polls / #{@lro_timeout}s).",
288
+ body: JSON.generate(response.body || {}),
289
+ code: "PollingTimeout",
290
+ http_status: response.status
291
+ )
292
+ end
293
+
294
+ sleep(retry_after(response))
295
+
296
+ if async_op_url
297
+ response = get(async_op_url)
298
+ state = provisioning_state(response.body)
299
+ case state
300
+ when "Succeeded"
301
+ return response.body
302
+ when "Failed", "Canceled"
303
+ raise OperationError.new(
304
+ "Long-running operation ended in state '#{state}'.",
305
+ body: JSON.generate(response.body || {}),
306
+ code: state,
307
+ http_status: response.status
308
+ )
309
+ end
310
+ async_op_url = response.headers["azure-asyncoperation"] || async_op_url
311
+ location_url = response.headers["location"] || location_url
312
+ else
313
+ response = get(location_url)
314
+ # Any non-202 response on a Location monitor is terminal success,
315
+ # even when there is no status body.
316
+ return response.body if response.status != 202
317
+
318
+ location_url = response.headers["location"] || location_url
319
+ end
320
+ end
321
+ end
322
+
323
+ def provisioning_state(body)
324
+ return nil unless body.is_a?(Hash)
325
+
326
+ body["status"] || (body["properties"] && body["properties"]["provisioningState"])
327
+ end
328
+
329
+ def retry_after(response)
330
+ parse_retry_after(response.headers && response.headers["retry-after"]) || @retry_interval
331
+ end
332
+ end
333
+ end
334
+ end
335
+ end
@@ -0,0 +1,103 @@
1
+ #
2
+ # Copyright:: Copyright (c) 2012-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved.
3
+ # License:: Apache License, Version 2.0
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+
18
+ module Azure
19
+ class ResourceManagement
20
+ module Rest
21
+ # Wraps a parsed ARM JSON response (a Hash) so existing consumers can keep
22
+ # using SDK-style dot notation. The retired Azure SDK exposed models with
23
+ # snake_case accessors and flattened the ARM "properties" envelope; this
24
+ # wrapper reproduces both behaviours:
25
+ #
26
+ # * Key matching ignores case and underscores, so camelCase ARM keys
27
+ # line up with the snake_case names the code already uses
28
+ # (e.g. "osType" <-> os_type, "publicIPAddress" <-> public_ipaddress).
29
+ # * When a name is not found at the top level, the nested "properties"
30
+ # object is searched too, mirroring the SDK's flattened models
31
+ # (e.g. vm.provisioning_state, vm.storage_profile).
32
+ class RestObject
33
+ def initialize(data)
34
+ @data = data || {}
35
+ end
36
+
37
+ # Wraps a raw value (Hash, Array or scalar) into RestObjects as needed.
38
+ def self.wrap(value)
39
+ case value
40
+ when RestObject
41
+ value
42
+ when Hash
43
+ new(value)
44
+ when Array
45
+ value.map { |v| wrap(v) }
46
+ else
47
+ value
48
+ end
49
+ end
50
+
51
+ def to_h
52
+ @data
53
+ end
54
+ alias_method :to_hash, :to_h
55
+
56
+ def [](key)
57
+ RestObject.wrap(@data[key])
58
+ end
59
+
60
+ def key?(name)
61
+ return true unless lookup_key(name).nil?
62
+
63
+ properties = @data["properties"]
64
+ properties.is_a?(Hash) && !lookup_key(name, properties).nil?
65
+ end
66
+
67
+ def nil?
68
+ @data.nil? || @data.empty?
69
+ end
70
+
71
+ def respond_to_missing?(_name, _include_private = false)
72
+ true
73
+ end
74
+
75
+ def method_missing(name, *args)
76
+ key = lookup_key(name)
77
+ return RestObject.wrap(@data[key]) unless key.nil?
78
+
79
+ properties = @data["properties"]
80
+ if properties.is_a?(Hash)
81
+ pkey = lookup_key(name, properties)
82
+ return RestObject.wrap(properties[pkey]) unless pkey.nil?
83
+ end
84
+
85
+ nil
86
+ end
87
+
88
+ private
89
+
90
+ def lookup_key(name, hash = @data)
91
+ return nil unless hash.is_a?(Hash)
92
+
93
+ target = normalize(name)
94
+ hash.keys.find { |k| normalize(k) == target }
95
+ end
96
+
97
+ def normalize(str)
98
+ str.to_s.delete("_").downcase
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,57 @@
1
+ #
2
+ # Copyright:: Copyright (c) 2012-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved.
3
+ # License:: Apache License, Version 2.0
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+
18
+ require "net/http" unless defined?(Net::HTTP)
19
+ require "openssl" unless defined?(OpenSSL)
20
+
21
+ module Azure
22
+ class ResourceManagement
23
+ module Rest
24
+ # Builds Net::HTTP connections with TLS certificate verification always
25
+ # enabled for HTTPS, so bearer tokens and client secrets are never sent
26
+ # over a channel where a forged/man-in-the-middle certificate could be
27
+ # accepted. All outbound connections in this layer go through here.
28
+ module SecureConnection
29
+ module_function
30
+
31
+ # Bounded, overridable defaults. Net::HTTP defaults both timeouts to
32
+ # unlimited, so without these a stalled Azure or token endpoint could
33
+ # block the CLI forever (and the HTTP layer's LRO deadline can't help
34
+ # while http.request itself is blocked).
35
+ DEFAULT_OPEN_TIMEOUT = 60 # seconds to establish the connection
36
+ DEFAULT_READ_TIMEOUT = 120 # seconds to wait for each response
37
+
38
+ def build(uri, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT)
39
+ http = Net::HTTP.new(uri.host, uri.port)
40
+ http.open_timeout = open_timeout
41
+ http.read_timeout = read_timeout
42
+ if uri.scheme == "https"
43
+ http.use_ssl = true
44
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
45
+ http.cert_store = default_cert_store
46
+ end
47
+ http
48
+ end
49
+
50
+ # System CA trust store, loaded once and reused.
51
+ def default_cert_store
52
+ @default_cert_store ||= OpenSSL::X509::Store.new.tap(&:set_default_paths)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,125 @@
1
+ #
2
+ # Copyright:: Copyright (c) 2012-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved.
3
+ # License:: Apache License, Version 2.0
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+
18
+ require "net/http" unless defined?(Net::HTTP)
19
+ require "uri" unless defined?(URI)
20
+ require "json" unless defined?(JSON)
21
+ require_relative "environments"
22
+ require_relative "errors"
23
+ require_relative "secure_connection"
24
+
25
+ module Azure
26
+ class ResourceManagement
27
+ module Rest
28
+ # Provides a bearer token for ARM requests. It supports the same two
29
+ # inputs the caller already produces (see azurerm_base.rb):
30
+ #
31
+ # * service principal: {azure_tenant_id, azure_client_id,
32
+ # azure_client_secret} -> client-credentials grant, cached until it
33
+ # is close to expiry;
34
+ # * pre-fetched token (the `az login` path): {token, tokentype} -> used
35
+ # as-is.
36
+ class TokenProvider
37
+ # Refresh a client-credentials token this many seconds before expiry.
38
+ EXPIRY_SKEW = 120
39
+
40
+ def initialize(params = {}, environment = Environments.default)
41
+ @params = params
42
+ @environment = environment
43
+ end
44
+
45
+ def authorization_header
46
+ "#{token_type} #{access_token}"
47
+ end
48
+
49
+ def token_type
50
+ @params[:tokentype] || "Bearer"
51
+ end
52
+
53
+ def access_token
54
+ if @params[:azure_client_secret]
55
+ client_credentials_token
56
+ else
57
+ @params[:token]
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def client_credentials_token
64
+ if @cached_token && @expires_at && Time.now.utc < (@expires_at - EXPIRY_SKEW)
65
+ return @cached_token
66
+ end
67
+
68
+ response = request_client_credentials_token
69
+ @cached_token = response["access_token"]
70
+ @expires_at = Time.now.utc + response["expires_in"].to_i
71
+ @cached_token
72
+ end
73
+
74
+ def request_client_credentials_token
75
+ uri = URI.parse("#{@environment.active_directory_endpoint_url}/#{@params[:azure_tenant_id]}/oauth2/token")
76
+ http = SecureConnection.build(uri)
77
+
78
+ request = Net::HTTP::Post.new(uri.request_uri)
79
+ request.set_form_data(
80
+ "grant_type" => "client_credentials",
81
+ "client_id" => @params[:azure_client_id],
82
+ "client_secret" => @params[:azure_client_secret],
83
+ "resource" => @environment.token_audience
84
+ )
85
+
86
+ raw = http.request(request)
87
+ if raw.code.to_i >= 400
88
+ raise OperationError.new(
89
+ token_error_message(raw),
90
+ body: raw.body,
91
+ http_status: raw.code.to_i
92
+ )
93
+ end
94
+
95
+ JSON.parse(raw.body)
96
+ end
97
+
98
+ # The AAD/OAuth token endpoint returns a flat error shape
99
+ # ({ "error": "invalid_client", "error_description": "..." }); tolerate
100
+ # an ARM-style nested error too, and fall back gracefully when the body
101
+ # is missing or not JSON.
102
+ def token_error_message(raw)
103
+ base = "Failed to acquire an Azure access token (HTTP #{raw.code})."
104
+ parsed = parse_json(raw.body)
105
+ return base unless parsed.is_a?(Hash)
106
+
107
+ detail =
108
+ if parsed["error"].is_a?(Hash)
109
+ parsed["error"]["message"]
110
+ else
111
+ parsed["error_description"] || parsed["error"]
112
+ end
113
+
114
+ detail ? "#{base} #{detail}" : base
115
+ end
116
+
117
+ def parse_json(body)
118
+ JSON.parse(body.to_s)
119
+ rescue JSON::ParserError
120
+ nil
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end