knife-azure 4.0.0 → 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.
@@ -0,0 +1,208 @@
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 "erb" unless defined?(ERB::Util)
19
+ require "uri" unless defined?(URI)
20
+ require_relative "environments"
21
+ require_relative "errors"
22
+ require_relative "http"
23
+ require_relative "rest_object"
24
+
25
+ module Azure
26
+ class ResourceManagement
27
+ module Rest
28
+ # One method per ARM operation used by knife-azure, replacing the four
29
+ # retired azure_mgmt_* SDK clients. Responses are wrapped in RestObject so
30
+ # callers keep using SDK-style dot notation.
31
+ class ArmClient
32
+ RESOURCES_API_VERSION = "2025-04-01".freeze
33
+ COMPUTE_API_VERSION = "2024-11-01".freeze
34
+ NETWORK_API_VERSION = "2025-07-01".freeze
35
+
36
+ def initialize(subscription_id, http, environment = Environments.default)
37
+ @subscription_id = subscription_id
38
+ @http = http
39
+ @environment = environment
40
+ end
41
+
42
+ # ----- Resource groups / deployments (Microsoft.Resources) -----
43
+
44
+ def resource_group_exist?(resource_group_name)
45
+ @http.head(rg_url(resource_group_name, RESOURCES_API_VERSION))
46
+ true
47
+ rescue OperationError => e
48
+ return false if e.http_status == 404
49
+
50
+ raise
51
+ end
52
+
53
+ def create_resource_group(resource_group_name, location)
54
+ body = { "location" => location }
55
+ response = @http.put(rg_url(resource_group_name, RESOURCES_API_VERSION), body)
56
+ RestObject.wrap(response.body)
57
+ end
58
+
59
+ def delete_resource_group(resource_group_name)
60
+ @http.request_and_poll(:delete, rg_url(resource_group_name, RESOURCES_API_VERSION))
61
+ nil
62
+ end
63
+
64
+ def create_deployment(resource_group_name, deployment_name, deployment_body)
65
+ url = deployment_url(resource_group_name, deployment_name)
66
+ @http.request_and_poll(:put, url, deployment_body)
67
+ response = @http.get(url)
68
+ RestObject.wrap(response.body)
69
+ end
70
+
71
+ # ----- Virtual machines / extensions (Microsoft.Compute) -----
72
+
73
+ def list_all_virtual_machines
74
+ url = base_url("/providers/Microsoft.Compute/virtualMachines", COMPUTE_API_VERSION)
75
+ @http.get_all(url).map { |vm| RestObject.wrap(vm) }
76
+ end
77
+
78
+ def list_virtual_machines(resource_group_name)
79
+ url = base_url(
80
+ "/resourceGroups/#{e(resource_group_name)}/providers/Microsoft.Compute/virtualMachines",
81
+ COMPUTE_API_VERSION
82
+ )
83
+ @http.get_all(url).map { |vm| RestObject.wrap(vm) }
84
+ end
85
+
86
+ def get_virtual_machine(resource_group_name, vm_name)
87
+ response = @http.get(vm_url(resource_group_name, vm_name))
88
+ RestObject.wrap(response.body)
89
+ end
90
+
91
+ def get_availability_set(resource_group_name, availability_set_name)
92
+ url = base_url(
93
+ "/resourceGroups/#{e(resource_group_name)}/providers/Microsoft.Compute/availabilitySets/#{e(availability_set_name)}",
94
+ COMPUTE_API_VERSION
95
+ )
96
+ response = @http.get(url)
97
+ RestObject.wrap(response.body)
98
+ end
99
+
100
+ def delete_virtual_machine(resource_group_name, vm_name)
101
+ @http.request_and_poll(:delete, vm_url(resource_group_name, vm_name))
102
+ nil
103
+ end
104
+
105
+ def get_vm_extension(resource_group_name, vm_name, extension_name, expand: nil)
106
+ url = vm_extension_url(resource_group_name, vm_name, extension_name)
107
+ url += "&$expand=#{e(expand)}" if expand
108
+ response = @http.get(url)
109
+ RestObject.wrap(response.body)
110
+ end
111
+
112
+ def create_vm_extension(resource_group_name, vm_name, extension_name, extension_body)
113
+ url = vm_extension_url(resource_group_name, vm_name, extension_name)
114
+ # The async PUT resolves to an operation-status document, not the
115
+ # extension itself, so GET the resource once polling completes.
116
+ @http.request_and_poll(:put, url, extension_body)
117
+ response = @http.get(url)
118
+ RestObject.wrap(response.body)
119
+ end
120
+
121
+ def list_vm_extension_versions(location, publisher, type)
122
+ path = "/providers/Microsoft.Compute/locations/#{e(location)}/publishers/#{e(publisher)}" \
123
+ "/artifacttypes/vmextension/types/#{e(type)}/versions"
124
+ # This endpoint returns a bare JSON array; get_all handles that shape
125
+ # as well as the paged { "value": ... } envelope.
126
+ @http.get_all(base_url(path, COMPUTE_API_VERSION)).map { |v| RestObject.wrap(v) }
127
+ end
128
+
129
+ # ----- Network (Microsoft.Network) -----
130
+
131
+ def get_network_interface(resource_group_name, name)
132
+ response = @http.get(network_url(resource_group_name, "networkInterfaces", name))
133
+ RestObject.wrap(response.body)
134
+ end
135
+
136
+ def get_public_ip_address(resource_group_name, name)
137
+ response = @http.get(network_url(resource_group_name, "publicIPAddresses", name))
138
+ RestObject.wrap(response.body)
139
+ end
140
+
141
+ def get_network_security_group(resource_group_name, name)
142
+ response = @http.get(network_url(resource_group_name, "networkSecurityGroups", name))
143
+ RestObject.wrap(response.body)
144
+ end
145
+
146
+ def get_virtual_network(resource_group_name, vnet_name)
147
+ response = @http.get(network_url(resource_group_name, "virtualNetworks", vnet_name))
148
+ RestObject.wrap(response.body)
149
+ end
150
+
151
+ def list_subnets(resource_group_name, vnet_name)
152
+ path = "/resourceGroups/#{e(resource_group_name)}/providers/Microsoft.Network" \
153
+ "/virtualNetworks/#{e(vnet_name)}/subnets"
154
+ url = base_url(path, NETWORK_API_VERSION)
155
+ @http.get_all(url).map { |subnet| RestObject.wrap(subnet) }
156
+ end
157
+
158
+ private
159
+
160
+ def base_url(path, api_version)
161
+ "#{@environment.resource_manager_endpoint_url}/subscriptions/#{e(@subscription_id)}" \
162
+ "#{path}?api-version=#{api_version}"
163
+ end
164
+
165
+ def rg_url(resource_group_name, api_version)
166
+ base_url("/resourceGroups/#{e(resource_group_name)}", api_version)
167
+ end
168
+
169
+ def deployment_url(resource_group_name, deployment_name)
170
+ base_url(
171
+ "/resourceGroups/#{e(resource_group_name)}/providers/Microsoft.Resources/deployments/#{e(deployment_name)}",
172
+ RESOURCES_API_VERSION
173
+ )
174
+ end
175
+
176
+ def vm_url(resource_group_name, vm_name)
177
+ base_url(
178
+ "/resourceGroups/#{e(resource_group_name)}/providers/Microsoft.Compute/virtualMachines/#{e(vm_name)}",
179
+ COMPUTE_API_VERSION
180
+ )
181
+ end
182
+
183
+ def vm_extension_url(resource_group_name, vm_name, extension_name)
184
+ base_url(
185
+ "/resourceGroups/#{e(resource_group_name)}/providers/Microsoft.Compute/virtualMachines/" \
186
+ "#{e(vm_name)}/extensions/#{e(extension_name)}",
187
+ COMPUTE_API_VERSION
188
+ )
189
+ end
190
+
191
+ def network_url(resource_group_name, resource_type, name)
192
+ base_url(
193
+ "/resourceGroups/#{e(resource_group_name)}/providers/Microsoft.Network/#{resource_type}/#{e(name)}",
194
+ NETWORK_API_VERSION
195
+ )
196
+ end
197
+
198
+ # Percent-encode a value for use as a single URI *path* segment.
199
+ # URI.encode_www_form_component is for query strings (it turns spaces
200
+ # into "+"), which is wrong inside a path; ERB::Util.url_encode emits
201
+ # %20 and escapes "/" so a name can never break out of its segment.
202
+ def e(value)
203
+ ERB::Util.url_encode(value.to_s)
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,89 @@
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
+ # ARM + Azure AD endpoints per cloud. Public cloud is the default; the
22
+ # other clouds are provided for completeness and parity with the retired
23
+ # Azure SDK, which supported them too.
24
+ class Environments
25
+ ENVIRONMENTS = {
26
+ "AzureCloud" => {
27
+ resource_manager_endpoint_url: "https://management.azure.com",
28
+ active_directory_endpoint_url: "https://login.microsoftonline.com",
29
+ token_audience: "https://management.azure.com/",
30
+ },
31
+ "AzureUSGovernment" => {
32
+ resource_manager_endpoint_url: "https://management.usgovcloudapi.net",
33
+ active_directory_endpoint_url: "https://login.microsoftonline.us",
34
+ token_audience: "https://management.usgovcloudapi.net/",
35
+ },
36
+ "AzureChinaCloud" => {
37
+ resource_manager_endpoint_url: "https://management.chinacloudapi.cn",
38
+ active_directory_endpoint_url: "https://login.chinacloudapi.cn",
39
+ token_audience: "https://management.chinacloudapi.cn/",
40
+ },
41
+ "AzureGermanCloud" => {
42
+ resource_manager_endpoint_url: "https://management.microsoftazure.de",
43
+ active_directory_endpoint_url: "https://login.microsoftonline.de",
44
+ token_audience: "https://management.microsoftazure.de/",
45
+ },
46
+ }.freeze
47
+
48
+ DEFAULT_ENVIRONMENT = "AzureCloud".freeze
49
+
50
+ attr_reader :resource_manager_endpoint_url, :active_directory_endpoint_url, :token_audience
51
+
52
+ def initialize(name = DEFAULT_ENVIRONMENT)
53
+ config = ENVIRONMENTS[name] || ENVIRONMENTS[DEFAULT_ENVIRONMENT]
54
+ @resource_manager_endpoint_url = config[:resource_manager_endpoint_url]
55
+ @active_directory_endpoint_url = config[:active_directory_endpoint_url]
56
+ @token_audience = config[:token_audience]
57
+ end
58
+
59
+ def self.default
60
+ new(DEFAULT_ENVIRONMENT)
61
+ end
62
+
63
+ # Resolves a caller-supplied cloud name (e.g. "AzureUSGovernment") to an
64
+ # Environments instance, defaulting to the public cloud only when no
65
+ # value was given. An unrecognized name is rejected outright: silently
66
+ # falling back to the public cloud would otherwise post service-principal
67
+ # secrets and ARM requests to the wrong (public) endpoints whenever an
68
+ # operator intending a sovereign cloud makes a typo.
69
+ def self.from_name(name)
70
+ normalized = name.to_s.strip
71
+ return new(DEFAULT_ENVIRONMENT) if normalized.empty?
72
+
73
+ unless ENVIRONMENTS.key?(normalized)
74
+ raise ArgumentError,
75
+ "Unknown Azure environment '#{normalized}'. Valid values are: #{names.join(", ")}."
76
+ end
77
+
78
+ new(normalized)
79
+ end
80
+
81
+ # Names of the clouds this client understands, exposed so callers can
82
+ # validate a user-provided setting.
83
+ def self.names
84
+ ENVIRONMENTS.keys
85
+ end
86
+ end
87
+ end
88
+ end
89
+ 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
+ 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