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.
- checksums.yaml +4 -4
- data/lib/azure/azure_interface.rb +1 -1
- data/lib/azure/custom_errors.rb +2 -2
- data/lib/azure/helpers.rb +1 -1
- data/lib/azure/resource_management/ARM_deployment_template.rb +82 -27
- data/lib/azure/resource_management/ARM_interface.rb +112 -118
- data/lib/azure/resource_management/rest/arm_client.rb +208 -0
- data/lib/azure/resource_management/rest/environments.rb +89 -0
- data/lib/azure/resource_management/rest/errors.rb +57 -0
- data/lib/azure/resource_management/rest/http.rb +335 -0
- data/lib/azure/resource_management/rest/rest_object.rb +103 -0
- data/lib/azure/resource_management/rest/secure_connection.rb +57 -0
- data/lib/azure/resource_management/rest/token_provider.rb +125 -0
- data/lib/azure/resource_management/vnet_config.rb +4 -4
- data/lib/azure/resource_management/windows_credentials.rb +3 -3
- data/lib/chef/knife/azurerm_server_create.rb +22 -10
- data/lib/chef/knife/azurerm_server_delete.rb +1 -1
- data/lib/chef/knife/azurerm_server_list.rb +1 -1
- data/lib/chef/knife/azurerm_server_show.rb +1 -1
- data/lib/chef/knife/bootstrap/bootstrapper.rb +142 -14
- data/lib/chef/knife/bootstrap/common_bootstrap_options.rb +4 -6
- data/lib/chef/knife/bootstrap_azurerm.rb +1 -1
- data/lib/chef/knife/helpers/azurerm_base.rb +170 -9
- data/lib/knife-azure/version.rb +2 -2
- metadata +37 -95
- data/lib/azure/service_management/ASM_interface.rb +0 -310
- data/lib/azure/service_management/ag.rb +0 -99
- data/lib/azure/service_management/certificate.rb +0 -235
- data/lib/azure/service_management/connection.rb +0 -102
- data/lib/azure/service_management/deploy.rb +0 -221
- data/lib/azure/service_management/disk.rb +0 -68
- data/lib/azure/service_management/host.rb +0 -184
- data/lib/azure/service_management/image.rb +0 -94
- data/lib/azure/service_management/loadbalancer.rb +0 -78
- data/lib/azure/service_management/rest.rb +0 -126
- data/lib/azure/service_management/role.rb +0 -717
- data/lib/azure/service_management/storageaccount.rb +0 -127
- data/lib/azure/service_management/utility.rb +0 -40
- data/lib/azure/service_management/vnet.rb +0 -134
- data/lib/chef/knife/azure_ag_create.rb +0 -73
- data/lib/chef/knife/azure_ag_list.rb +0 -35
- data/lib/chef/knife/azure_image_list.rb +0 -56
- data/lib/chef/knife/azure_internal-lb_create.rb +0 -74
- data/lib/chef/knife/azure_internal-lb_list.rb +0 -35
- data/lib/chef/knife/azure_server_create.rb +0 -531
- data/lib/chef/knife/azure_server_delete.rb +0 -136
- data/lib/chef/knife/azure_server_list.rb +0 -38
- data/lib/chef/knife/azure_server_show.rb +0 -41
- data/lib/chef/knife/azure_vnet_create.rb +0 -74
- data/lib/chef/knife/azure_vnet_list.rb +0 -35
- data/lib/chef/knife/bootstrap_azure.rb +0 -191
- 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
|