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.
- checksums.yaml +4 -4
- data/lib/azure/azure_interface.rb +1 -1
- data/lib/azure/custom_errors.rb +1 -1
- data/lib/azure/helpers.rb +1 -1
- data/lib/azure/resource_management/ARM_deployment_template.rb +77 -22
- data/lib/azure/resource_management/ARM_interface.rb +115 -119
- 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 +1 -1
- data/lib/chef/knife/azurerm_server_create.rb +21 -9
- 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 +137 -4
- 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 +168 -7
- data/lib/knife-azure/version.rb +2 -2
- metadata +25 -70
|
@@ -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
|