kitchen-azurerm 1.14.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/kitchen/driver/azure/arm_client.rb +198 -0
- data/lib/kitchen/driver/azure/environments.rb +105 -0
- data/lib/kitchen/driver/azure/errors.rb +50 -0
- data/lib/kitchen/driver/azure/http.rb +120 -0
- data/lib/kitchen/driver/azure/token_provider.rb +257 -0
- data/lib/kitchen/driver/azure_credentials.rb +76 -85
- data/lib/kitchen/driver/azurerm.rb +221 -155
- data/lib/kitchen/driver/azurerm_version.rb +1 -1
- data/templates/internal.erb +57 -126
- data/templates/public.erb +39 -107
- metadata +7 -42
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f429ccdaffbdba117dea3b15b9e8beff46b7e5d1e0e1b4e23f0ab2565b652685
|
|
4
|
+
data.tar.gz: cd8ac4c9961e845d121331825a74bc20c85d0317db726f58d5954aedb8b5ffe0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 03a1ed78caa550f245b466c9cfb218dda27d45f50e2b158371e4b14b9f1d45e30d478c277a0ccf437a00855d8dd2f8b8d883d51578b66d750d51c355859d1073
|
|
7
|
+
data.tar.gz: 181aa2863481530c6d2d78d2d050918dedbc71f51723928eb166dd8ae78d7dd966090f5a112a6202b74d160fba62cf695b29e86335853038672dc342e8577628
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
require "json" unless defined?(JSON)
|
|
2
|
+
require "uri" unless defined?(URI)
|
|
3
|
+
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
require_relative "http"
|
|
6
|
+
|
|
7
|
+
module Kitchen
|
|
8
|
+
module Driver
|
|
9
|
+
module Azure
|
|
10
|
+
# A minimal Azure Resource Manager client covering exactly the operations
|
|
11
|
+
# this driver performs.
|
|
12
|
+
#
|
|
13
|
+
# Replaces +azure_mgmt_resources2+, +azure_mgmt_network2+, +ms_rest2+ and
|
|
14
|
+
# +ms_rest_azure2+, which are forks of Microsoft's retired Azure SDK for
|
|
15
|
+
# Ruby. Responses are returned as parsed JSON, so callers see ARM's own
|
|
16
|
+
# camelCase property names rather than the SDK's generated model objects.
|
|
17
|
+
class ArmClient
|
|
18
|
+
# ARM API version used for resource group and deployment requests.
|
|
19
|
+
#
|
|
20
|
+
# @return [String]
|
|
21
|
+
RESOURCES_API_VERSION = "2025-04-01".freeze
|
|
22
|
+
|
|
23
|
+
# ARM API version used for network resource requests.
|
|
24
|
+
#
|
|
25
|
+
# @return [String]
|
|
26
|
+
NETWORK_API_VERSION = "2025-07-01".freeze
|
|
27
|
+
|
|
28
|
+
# @param subscription_id [String]
|
|
29
|
+
# @param environment [Environments::Environment]
|
|
30
|
+
# @param token_provider [TokenProvider]
|
|
31
|
+
def initialize(subscription_id:, environment:, token_provider:)
|
|
32
|
+
@subscription_id = subscription_id
|
|
33
|
+
@environment = environment
|
|
34
|
+
@token_provider = token_provider
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [String]
|
|
38
|
+
attr_reader :subscription_id
|
|
39
|
+
|
|
40
|
+
# @return [Environments::Environment]
|
|
41
|
+
attr_reader :environment
|
|
42
|
+
|
|
43
|
+
# @return [TokenProvider]
|
|
44
|
+
attr_reader :token_provider
|
|
45
|
+
|
|
46
|
+
# Whether a resource group exists.
|
|
47
|
+
#
|
|
48
|
+
# @param name [String] resource group name, case-insensitive.
|
|
49
|
+
# @return [Boolean]
|
|
50
|
+
def resource_group_exists?(name)
|
|
51
|
+
response = call(:head, resource_group_path(name), api_version: RESOURCES_API_VERSION, allow: [404])
|
|
52
|
+
response.status != 404
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Creates or updates a resource group.
|
|
56
|
+
#
|
|
57
|
+
# @param name [String] resource group name.
|
|
58
|
+
# @param location [String] Azure region.
|
|
59
|
+
# @param tags [Hash] resource tags.
|
|
60
|
+
# @return [Hash] the resource group as ARM returned it.
|
|
61
|
+
def create_or_update_resource_group(name, location:, tags: {})
|
|
62
|
+
call(:put, resource_group_path(name),
|
|
63
|
+
api_version: RESOURCES_API_VERSION,
|
|
64
|
+
body: { "location" => location, "tags" => tags || {} }).json
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Requests deletion of a resource group, returning as soon as ARM
|
|
68
|
+
# accepts the request rather than waiting for it to finish.
|
|
69
|
+
#
|
|
70
|
+
# @param name [String] resource group name.
|
|
71
|
+
# @return [void]
|
|
72
|
+
def delete_resource_group(name)
|
|
73
|
+
call(:delete, resource_group_path(name), api_version: RESOURCES_API_VERSION)
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Submits a deployment, returning once ARM accepts it.
|
|
78
|
+
#
|
|
79
|
+
# @param resource_group [String]
|
|
80
|
+
# @param name [String] deployment name.
|
|
81
|
+
# @param deployment [Hash] the deployment body, as built by the driver.
|
|
82
|
+
# @return [Hash] the deployment as ARM returned it.
|
|
83
|
+
def create_deployment(resource_group, name, deployment)
|
|
84
|
+
call(:put, deployment_path(resource_group, name),
|
|
85
|
+
api_version: RESOURCES_API_VERSION,
|
|
86
|
+
body: deployment).json
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Reads a deployment.
|
|
90
|
+
#
|
|
91
|
+
# @param resource_group [String]
|
|
92
|
+
# @param name [String] deployment name.
|
|
93
|
+
# @return [Hash]
|
|
94
|
+
def deployment(resource_group, name)
|
|
95
|
+
call(:get, deployment_path(resource_group, name), api_version: RESOURCES_API_VERSION).json
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Lists every operation belonging to a deployment.
|
|
99
|
+
#
|
|
100
|
+
# @param resource_group [String]
|
|
101
|
+
# @param name [String] deployment name.
|
|
102
|
+
# @return [Array<Hash>]
|
|
103
|
+
def deployment_operations(resource_group, name)
|
|
104
|
+
payload = call(:get, "#{deployment_path(resource_group, name)}/operations",
|
|
105
|
+
api_version: RESOURCES_API_VERSION).json
|
|
106
|
+
|
|
107
|
+
payload.is_a?(Hash) ? Array(payload["value"]) : []
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Reads a public IP address resource.
|
|
111
|
+
#
|
|
112
|
+
# @param resource_group [String]
|
|
113
|
+
# @param name [String] public IP resource name.
|
|
114
|
+
# @return [Hash]
|
|
115
|
+
def public_ip(resource_group, name)
|
|
116
|
+
call(:get, network_path(resource_group, "publicIPAddresses", name),
|
|
117
|
+
api_version: NETWORK_API_VERSION).json
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Reads a network interface resource.
|
|
121
|
+
#
|
|
122
|
+
# @param resource_group [String]
|
|
123
|
+
# @param name [String] network interface name.
|
|
124
|
+
# @return [Hash]
|
|
125
|
+
def network_interface(resource_group, name)
|
|
126
|
+
call(:get, network_path(resource_group, "networkInterfaces", name),
|
|
127
|
+
api_version: NETWORK_API_VERSION).json
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
# @param name [String]
|
|
133
|
+
# @return [String]
|
|
134
|
+
def resource_group_path(name)
|
|
135
|
+
"/subscriptions/#{subscription_id}/resourcegroups/#{escape(name)}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @param resource_group [String]
|
|
139
|
+
# @param name [String]
|
|
140
|
+
# @return [String]
|
|
141
|
+
def deployment_path(resource_group, name)
|
|
142
|
+
"#{resource_group_path(resource_group)}/providers/Microsoft.Resources/deployments/#{escape(name)}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @param resource_group [String]
|
|
146
|
+
# @param type [String] e.g. +"publicIPAddresses"+.
|
|
147
|
+
# @param name [String]
|
|
148
|
+
# @return [String]
|
|
149
|
+
def network_path(resource_group, type, name)
|
|
150
|
+
"#{resource_group_path(resource_group)}/providers/Microsoft.Network/#{type}/#{escape(name)}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# @param value [String]
|
|
154
|
+
# @return [String] path-escaped
|
|
155
|
+
def escape(value)
|
|
156
|
+
URI::DEFAULT_PARSER.escape(value.to_s)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Performs an authenticated ARM request.
|
|
160
|
+
#
|
|
161
|
+
# @param method [Symbol]
|
|
162
|
+
# @param path [String] path below the resource manager URL.
|
|
163
|
+
# @param api_version [String]
|
|
164
|
+
# @param body [Hash, nil] request body, serialized as JSON.
|
|
165
|
+
# @param allow [Array<Integer>] non-2xx statuses to treat as success.
|
|
166
|
+
# @return [Http::Response]
|
|
167
|
+
# @raise [OperationError] when ARM returns an unexpected status.
|
|
168
|
+
def call(method, path, api_version:, body: nil, allow: [])
|
|
169
|
+
url = "#{environment.resource_manager_url.chomp("/")}#{path}?api-version=#{api_version}"
|
|
170
|
+
headers = {
|
|
171
|
+
"Authorization" => token_provider.authorization_header,
|
|
172
|
+
"Accept" => "application/json",
|
|
173
|
+
"User-Agent" => "kitchen-azurerm/#{Kitchen::Driver::AZURERM_VERSION}",
|
|
174
|
+
}
|
|
175
|
+
headers["Content-Type"] = "application/json" if body
|
|
176
|
+
|
|
177
|
+
response = Http.request(method:, url:, headers:, body: body && JSON.generate(body))
|
|
178
|
+
return response if response.success? || allow.include?(response.status)
|
|
179
|
+
|
|
180
|
+
raise OperationError.new(
|
|
181
|
+
"Azure returned HTTP #{response.status} for #{method.to_s.upcase} #{path}",
|
|
182
|
+
status: response.status,
|
|
183
|
+
body: error_body(response)
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# @param response [Http::Response]
|
|
188
|
+
# @return [Hash] the parsed error body, or a synthesized one.
|
|
189
|
+
def error_body(response)
|
|
190
|
+
parsed = response.json
|
|
191
|
+
return parsed if parsed.is_a?(Hash) && parsed.key?("error")
|
|
192
|
+
|
|
193
|
+
{ "error" => { "code" => "Unknown", "message" => response.body.to_s } }
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
module Kitchen
|
|
2
|
+
module Driver
|
|
3
|
+
module Azure
|
|
4
|
+
# Endpoints for each Azure cloud the driver can target.
|
|
5
|
+
#
|
|
6
|
+
# These used to come from +MsRestAzure2::AzureEnvironments+ and
|
|
7
|
+
# +MsRestAzure2::ActiveDirectoryServiceSettings+. They are stable,
|
|
8
|
+
# published values, so carrying the table ourselves costs a few lines and
|
|
9
|
+
# removes a dependency on a retired SDK.
|
|
10
|
+
module Environments
|
|
11
|
+
# One Azure cloud's endpoints.
|
|
12
|
+
#
|
|
13
|
+
# @!attribute [r] name
|
|
14
|
+
# @return [String] canonical cloud name, e.g. +"AzureUSGovernment"+.
|
|
15
|
+
# @!attribute [r] resource_manager_url
|
|
16
|
+
# @return [String] base URL for Azure Resource Manager requests.
|
|
17
|
+
# @!attribute [r] authentication_endpoint
|
|
18
|
+
# @return [String] Entra ID (Azure AD) token endpoint.
|
|
19
|
+
# @!attribute [r] token_audience
|
|
20
|
+
# @return [String] audience/resource the access token is requested for.
|
|
21
|
+
Environment = Struct.new(:name, :resource_manager_url, :authentication_endpoint, :token_audience) do
|
|
22
|
+
# The OAuth2 v1.0 token URL for a tenant in this cloud.
|
|
23
|
+
#
|
|
24
|
+
# @param tenant_id [String]
|
|
25
|
+
# @return [String]
|
|
26
|
+
def token_url(tenant_id)
|
|
27
|
+
"#{authentication_endpoint.chomp("/")}/#{tenant_id}/oauth2/token"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The OAuth2 v2.0 token URL for a tenant in this cloud.
|
|
31
|
+
#
|
|
32
|
+
# Federated (assertion-based) client credentials are documented
|
|
33
|
+
# against v2.0, which takes a +scope+ rather than a +resource+.
|
|
34
|
+
#
|
|
35
|
+
# @param tenant_id [String]
|
|
36
|
+
# @return [String]
|
|
37
|
+
def token_url_v2(tenant_id)
|
|
38
|
+
"#{authentication_endpoint.chomp("/")}/#{tenant_id}/oauth2/v2.0/token"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The v2.0 scope covering every permission this driver needs.
|
|
42
|
+
#
|
|
43
|
+
# @return [String] e.g. +"https://management.azure.com/.default"+
|
|
44
|
+
def default_scope
|
|
45
|
+
"#{resource_manager_url.chomp("/")}/.default"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A copy of this cloud pointed at a different Entra ID authority.
|
|
49
|
+
#
|
|
50
|
+
# Platforms that issue federated tokens - AKS in particular - set
|
|
51
|
+
# +AZURE_AUTHORITY_HOST+ to say where those tokens should be
|
|
52
|
+
# exchanged.
|
|
53
|
+
#
|
|
54
|
+
# @param authority [String, nil] the authority URL, or nil to keep this one.
|
|
55
|
+
# @return [Environment]
|
|
56
|
+
def with_authority(authority)
|
|
57
|
+
return self if authority.to_s.empty?
|
|
58
|
+
|
|
59
|
+
self.class.new(name, resource_manager_url, authority, token_audience).freeze
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Every supported cloud, keyed by its downcased name so that lookups are
|
|
64
|
+
# case-insensitive.
|
|
65
|
+
#
|
|
66
|
+
# @return [Hash{String => Environment}]
|
|
67
|
+
ALL = [
|
|
68
|
+
Environment.new("Azure",
|
|
69
|
+
"https://management.azure.com/",
|
|
70
|
+
"https://login.microsoftonline.com/",
|
|
71
|
+
"https://management.core.windows.net/"),
|
|
72
|
+
Environment.new("AzureUSGovernment",
|
|
73
|
+
"https://management.usgovcloudapi.net",
|
|
74
|
+
"https://login.microsoftonline.us/",
|
|
75
|
+
"https://management.core.usgovcloudapi.net/"),
|
|
76
|
+
Environment.new("AzureChina",
|
|
77
|
+
"https://management.chinacloudapi.cn",
|
|
78
|
+
"https://login.chinacloudapi.cn/",
|
|
79
|
+
"https://management.core.chinacloudapi.cn/"),
|
|
80
|
+
Environment.new("AzureGermanCloud",
|
|
81
|
+
"https://management.microsoftazure.de",
|
|
82
|
+
"https://login.microsoftonline.de/",
|
|
83
|
+
"https://management.core.cloudapi.de/"),
|
|
84
|
+
].each(&:freeze).to_h { |environment| [environment.name.downcase, environment] }.freeze
|
|
85
|
+
|
|
86
|
+
# Looks a cloud up by name.
|
|
87
|
+
#
|
|
88
|
+
# @param name [String] cloud name, case-insensitive.
|
|
89
|
+
# @return [Environment]
|
|
90
|
+
# @raise [Kitchen::UserError] if the name is not a known Azure cloud.
|
|
91
|
+
def self.fetch(name)
|
|
92
|
+
ALL.fetch(name.to_s.downcase) do
|
|
93
|
+
raise Kitchen::UserError,
|
|
94
|
+
"Unknown azure_environment '#{name}'. Valid values are: #{names.join(", ")} (case-insensitive)."
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @return [Array<String>] the canonical name of every supported cloud.
|
|
99
|
+
def self.names
|
|
100
|
+
ALL.values.map(&:name)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Kitchen
|
|
2
|
+
module Driver
|
|
3
|
+
# Direct Azure Resource Manager access: endpoints, authentication, and the
|
|
4
|
+
# handful of REST calls this driver makes.
|
|
5
|
+
#
|
|
6
|
+
# This replaces the +azure_mgmt_*+ and +ms_rest*+ gems, which are forks of
|
|
7
|
+
# Microsoft's retired Azure SDK for Ruby.
|
|
8
|
+
module Azure
|
|
9
|
+
# Raised when Azure Resource Manager returns an error response.
|
|
10
|
+
#
|
|
11
|
+
# Replaces +MsRestAzure2::AzureOperationError+, keeping the +body+
|
|
12
|
+
# accessor the driver's rescue blocks already rely on.
|
|
13
|
+
#
|
|
14
|
+
# Note the explicit +::StandardError+: Test Kitchen defines
|
|
15
|
+
# +Kitchen::StandardError+, and this class lives inside +module Kitchen+,
|
|
16
|
+
# so a bare +StandardError+ here would resolve to that instead.
|
|
17
|
+
class OperationError < ::StandardError
|
|
18
|
+
# The parsed error body, as returned by ARM.
|
|
19
|
+
#
|
|
20
|
+
# @return [Hash] typically +{"error" => {"code" => ..., "message" => ...}}+.
|
|
21
|
+
attr_reader :body
|
|
22
|
+
|
|
23
|
+
# The HTTP status code of the failing response.
|
|
24
|
+
#
|
|
25
|
+
# @return [Integer]
|
|
26
|
+
attr_reader :status
|
|
27
|
+
|
|
28
|
+
# @param message [String] human-readable summary.
|
|
29
|
+
# @param status [Integer] HTTP status code.
|
|
30
|
+
# @param body [Hash] parsed ARM error body.
|
|
31
|
+
def initialize(message, status: nil, body: {})
|
|
32
|
+
super(message)
|
|
33
|
+
@status = status
|
|
34
|
+
@body = body
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The Azure error code, when ARM supplied one.
|
|
38
|
+
#
|
|
39
|
+
# @return [String, nil] e.g. +"DeploymentActive"+.
|
|
40
|
+
def code
|
|
41
|
+
body.is_a?(Hash) ? body.dig("error", "code") : nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Raised when a request could not be completed and is worth retrying:
|
|
46
|
+
# timeouts, resets, DNS failures and the like.
|
|
47
|
+
class TransientError < ::StandardError; end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
require "json" unless defined?(JSON)
|
|
2
|
+
require "net/http" unless defined?(Net::HTTP)
|
|
3
|
+
require "openssl" unless defined?(OpenSSL)
|
|
4
|
+
require "uri" unless defined?(URI)
|
|
5
|
+
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
|
|
8
|
+
module Kitchen
|
|
9
|
+
module Driver
|
|
10
|
+
module Azure
|
|
11
|
+
# A very small JSON-over-HTTP helper built on the standard library.
|
|
12
|
+
#
|
|
13
|
+
# The driver makes a handful of straightforward requests, so this replaces
|
|
14
|
+
# Faraday and its middleware chain rather than depending on them.
|
|
15
|
+
module Http
|
|
16
|
+
# Network-level failures worth retrying rather than surfacing.
|
|
17
|
+
#
|
|
18
|
+
# @return [Array<Class>]
|
|
19
|
+
TRANSIENT_ERRORS = [
|
|
20
|
+
Net::OpenTimeout,
|
|
21
|
+
Net::ReadTimeout,
|
|
22
|
+
Errno::ECONNREFUSED,
|
|
23
|
+
Errno::ECONNRESET,
|
|
24
|
+
Errno::EHOSTUNREACH,
|
|
25
|
+
Errno::ENETUNREACH,
|
|
26
|
+
Errno::EPIPE,
|
|
27
|
+
EOFError,
|
|
28
|
+
SocketError,
|
|
29
|
+
OpenSSL::SSL::SSLError,
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
# Seconds to wait for a connection and for a response.
|
|
33
|
+
#
|
|
34
|
+
# @return [Integer]
|
|
35
|
+
OPEN_TIMEOUT = 30
|
|
36
|
+
# @return [Integer]
|
|
37
|
+
READ_TIMEOUT = 120
|
|
38
|
+
|
|
39
|
+
# One HTTP response.
|
|
40
|
+
#
|
|
41
|
+
# @!attribute [r] status
|
|
42
|
+
# @return [Integer]
|
|
43
|
+
# @!attribute [r] body
|
|
44
|
+
# @return [String]
|
|
45
|
+
Response = Struct.new(:status, :body) do
|
|
46
|
+
# @return [Boolean] whether the status is in the 2xx range.
|
|
47
|
+
def success?
|
|
48
|
+
status.between?(200, 299)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The response body parsed as JSON.
|
|
52
|
+
#
|
|
53
|
+
# @return [Hash, Array, nil] nil when the body is empty or not JSON.
|
|
54
|
+
def json
|
|
55
|
+
return nil if body.nil? || body.empty?
|
|
56
|
+
|
|
57
|
+
JSON.parse(body)
|
|
58
|
+
rescue JSON::ParserError
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Performs a request.
|
|
64
|
+
#
|
|
65
|
+
# @param method [Symbol] +:get+, +:head+, +:put+, +:post+ or +:delete+.
|
|
66
|
+
# @param url [String] absolute URL.
|
|
67
|
+
# @param headers [Hash{String => String}] request headers.
|
|
68
|
+
# @param body [String, nil] request body, already encoded.
|
|
69
|
+
# @return [Response]
|
|
70
|
+
# @raise [TransientError] on a network failure worth retrying.
|
|
71
|
+
def self.request(method:, url:, headers: {}, body: nil)
|
|
72
|
+
uri = URI.parse(url)
|
|
73
|
+
request = request_class(method).new(uri)
|
|
74
|
+
headers.each { |name, value| request[name] = value }
|
|
75
|
+
request.body = body if body
|
|
76
|
+
|
|
77
|
+
response = perform(uri, request)
|
|
78
|
+
Response.new(response.code.to_i, response.body.to_s)
|
|
79
|
+
rescue *TRANSIENT_ERRORS => e
|
|
80
|
+
raise TransientError, "#{e.class}: #{e.message}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Sends a request, honouring the proxy environment variables.
|
|
84
|
+
#
|
|
85
|
+
# @param uri [URI]
|
|
86
|
+
# @param request [Net::HTTPRequest]
|
|
87
|
+
# @return [Net::HTTPResponse]
|
|
88
|
+
# @api private
|
|
89
|
+
def self.perform(uri, request)
|
|
90
|
+
proxy = uri.find_proxy
|
|
91
|
+
http = if proxy
|
|
92
|
+
Net::HTTP.new(uri.host, uri.port, proxy.host, proxy.port, proxy.user, proxy.password)
|
|
93
|
+
else
|
|
94
|
+
Net::HTTP.new(uri.host, uri.port)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
http.use_ssl = uri.scheme == "https"
|
|
98
|
+
http.open_timeout = OPEN_TIMEOUT
|
|
99
|
+
http.read_timeout = READ_TIMEOUT
|
|
100
|
+
http.start { |connection| connection.request(request) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @param method [Symbol]
|
|
104
|
+
# @return [Class] the matching +Net::HTTP+ request class.
|
|
105
|
+
# @raise [ArgumentError] for an unsupported method.
|
|
106
|
+
# @api private
|
|
107
|
+
def self.request_class(method)
|
|
108
|
+
case method
|
|
109
|
+
when :get then Net::HTTP::Get
|
|
110
|
+
when :head then Net::HTTP::Head
|
|
111
|
+
when :put then Net::HTTP::Put
|
|
112
|
+
when :post then Net::HTTP::Post
|
|
113
|
+
when :delete then Net::HTTP::Delete
|
|
114
|
+
else raise ArgumentError, "Unsupported HTTP method: #{method}"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|