kitchen-azurerm 1.13.6 → 2.0.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 +73 -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 +196 -0
- data/lib/kitchen/driver/azure_credentials.rb +121 -93
- data/lib/kitchen/driver/azurerm.rb +698 -438
- data/lib/kitchen/driver/azurerm_version.rb +7 -1
- data/templates/internal.erb +71 -138
- data/templates/public.erb +53 -119
- 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: '0853e2f879329c6b0664e4931574d1ca3a8b8998fc1a5e56424eab212b2f95b1'
|
|
4
|
+
data.tar.gz: e00246cb4d2f291492df022874e9755fb6dc7745067428dd2f1ac291e57fc32f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c8016844395a76393d56fcf31d30b7cb4e14512566c0da7a3a73aeb5fb120f4232d6c1c678feb5ca4d7cd9b159613991b0c759764ff549245e82e5055d8a3ba3
|
|
7
|
+
data.tar.gz: ec9337480b1b58d0b0a5dc461c99e8d1abbf97e9f0b95dfc8c41b34acf3a4580b0701dfaf0323dd7660164f27602bc9ef912b29cab7cbba5e66a6a99fd7b5cbe
|
|
@@ -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,73 @@
|
|
|
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 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
|
+
end
|
|
30
|
+
|
|
31
|
+
# Every supported cloud, keyed by its downcased name so that lookups are
|
|
32
|
+
# case-insensitive.
|
|
33
|
+
#
|
|
34
|
+
# @return [Hash{String => Environment}]
|
|
35
|
+
ALL = [
|
|
36
|
+
Environment.new("Azure",
|
|
37
|
+
"https://management.azure.com/",
|
|
38
|
+
"https://login.microsoftonline.com/",
|
|
39
|
+
"https://management.core.windows.net/"),
|
|
40
|
+
Environment.new("AzureUSGovernment",
|
|
41
|
+
"https://management.usgovcloudapi.net",
|
|
42
|
+
"https://login.microsoftonline.us/",
|
|
43
|
+
"https://management.core.usgovcloudapi.net/"),
|
|
44
|
+
Environment.new("AzureChina",
|
|
45
|
+
"https://management.chinacloudapi.cn",
|
|
46
|
+
"https://login.chinacloudapi.cn/",
|
|
47
|
+
"https://management.core.chinacloudapi.cn/"),
|
|
48
|
+
Environment.new("AzureGermanCloud",
|
|
49
|
+
"https://management.microsoftazure.de",
|
|
50
|
+
"https://login.microsoftonline.de/",
|
|
51
|
+
"https://management.core.cloudapi.de/"),
|
|
52
|
+
].each(&:freeze).to_h { |environment| [environment.name.downcase, environment] }.freeze
|
|
53
|
+
|
|
54
|
+
# Looks a cloud up by name.
|
|
55
|
+
#
|
|
56
|
+
# @param name [String] cloud name, case-insensitive.
|
|
57
|
+
# @return [Environment]
|
|
58
|
+
# @raise [Kitchen::UserError] if the name is not a known Azure cloud.
|
|
59
|
+
def self.fetch(name)
|
|
60
|
+
ALL.fetch(name.to_s.downcase) do
|
|
61
|
+
raise Kitchen::UserError,
|
|
62
|
+
"Unknown azure_environment '#{name}'. Valid values are: #{names.join(", ")} (case-insensitive)."
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Array<String>] the canonical name of every supported cloud.
|
|
67
|
+
def self.names
|
|
68
|
+
ALL.values.map(&:name)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
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
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
require "json" unless defined?(JSON)
|
|
2
|
+
require "open3" unless defined?(Open3)
|
|
3
|
+
require "time" unless defined?(Time.parse)
|
|
4
|
+
require "uri" unless defined?(URI)
|
|
5
|
+
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
require_relative "http"
|
|
8
|
+
|
|
9
|
+
module Kitchen
|
|
10
|
+
module Driver
|
|
11
|
+
module Azure
|
|
12
|
+
# Acquires and caches Entra ID (Azure AD) access tokens for ARM.
|
|
13
|
+
#
|
|
14
|
+
# Replaces the +MsRestAzure2+ token providers. Each subclass knows how to
|
|
15
|
+
# fetch a token one way; the caching and expiry handling is shared.
|
|
16
|
+
class TokenProvider
|
|
17
|
+
# Seconds before actual expiry at which a cached token is considered
|
|
18
|
+
# stale, so a long deployment does not fail mid-flight.
|
|
19
|
+
#
|
|
20
|
+
# @return [Integer]
|
|
21
|
+
EXPIRY_MARGIN = 300
|
|
22
|
+
|
|
23
|
+
# @param environment [Environments::Environment] the target cloud.
|
|
24
|
+
def initialize(environment:)
|
|
25
|
+
@environment = environment
|
|
26
|
+
@token = nil
|
|
27
|
+
@expires_at = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [Environments::Environment]
|
|
31
|
+
attr_reader :environment
|
|
32
|
+
|
|
33
|
+
# A valid access token, fetching or refreshing one if needed.
|
|
34
|
+
#
|
|
35
|
+
# @return [String]
|
|
36
|
+
def access_token
|
|
37
|
+
return @token if @token && @expires_at && Time.now.to_i < @expires_at - EXPIRY_MARGIN
|
|
38
|
+
|
|
39
|
+
@token, @expires_at = fetch_token
|
|
40
|
+
@token
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The value for the Authorization header.
|
|
44
|
+
#
|
|
45
|
+
# @return [String]
|
|
46
|
+
def authorization_header
|
|
47
|
+
"Bearer #{access_token}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Fetches a fresh token.
|
|
51
|
+
#
|
|
52
|
+
# @return [Array(String, Integer)] the token and its expiry, as a Unix time.
|
|
53
|
+
# @raise [NotImplementedError] subclasses must implement this.
|
|
54
|
+
def fetch_token
|
|
55
|
+
raise NotImplementedError
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# Turns a token endpoint response into a token and expiry pair.
|
|
61
|
+
#
|
|
62
|
+
# @param response [Http::Response]
|
|
63
|
+
# @param source [String] describes the endpoint, for error messages.
|
|
64
|
+
# @return [Array(String, Integer)]
|
|
65
|
+
# @raise [OperationError] if the endpoint did not return a token.
|
|
66
|
+
def token_from(response, source)
|
|
67
|
+
payload = response.json
|
|
68
|
+
unless response.success? && payload.is_a?(Hash) && payload["access_token"]
|
|
69
|
+
raise OperationError.new(
|
|
70
|
+
"Could not acquire an Azure access token from #{source} (HTTP #{response.status}).",
|
|
71
|
+
status: response.status,
|
|
72
|
+
body: payload.is_a?(Hash) ? payload : {}
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
[payload["access_token"], expiry_from(payload)]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Reads the expiry out of a token response, which spells it differently
|
|
80
|
+
# depending on the endpoint.
|
|
81
|
+
#
|
|
82
|
+
# @param payload [Hash]
|
|
83
|
+
# @return [Integer] Unix time at which the token expires.
|
|
84
|
+
def expiry_from(payload)
|
|
85
|
+
return payload["expires_on"].to_i if payload["expires_on"].to_s.match?(/\A\d+\z/)
|
|
86
|
+
return Time.now.to_i + payload["expires_in"].to_i if payload["expires_in"]
|
|
87
|
+
|
|
88
|
+
Time.now.to_i + 3600
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Authenticates as a service principal, using a client id and secret.
|
|
93
|
+
class ServicePrincipalToken < TokenProvider
|
|
94
|
+
# @param environment [Environments::Environment]
|
|
95
|
+
# @param tenant_id [String]
|
|
96
|
+
# @param client_id [String]
|
|
97
|
+
# @param client_secret [String]
|
|
98
|
+
def initialize(environment:, tenant_id:, client_id:, client_secret:)
|
|
99
|
+
super(environment:)
|
|
100
|
+
@tenant_id = tenant_id
|
|
101
|
+
@client_id = client_id
|
|
102
|
+
@client_secret = client_secret
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @return [Array(String, Integer)]
|
|
106
|
+
def fetch_token
|
|
107
|
+
response = Http.request(
|
|
108
|
+
method: :post,
|
|
109
|
+
url: environment.token_url(@tenant_id),
|
|
110
|
+
headers: { "Content-Type" => "application/x-www-form-urlencoded" },
|
|
111
|
+
body: URI.encode_www_form(
|
|
112
|
+
grant_type: "client_credentials",
|
|
113
|
+
client_id: @client_id,
|
|
114
|
+
client_secret: @client_secret,
|
|
115
|
+
resource: environment.token_audience
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
token_from(response, "the service principal token endpoint")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Authenticates as a managed identity, via the Instance Metadata Service.
|
|
124
|
+
#
|
|
125
|
+
# This replaces the legacy MSI extension endpoint on port 50342 that the
|
|
126
|
+
# old SDK used; IMDS is the supported endpoint on modern Azure VMs.
|
|
127
|
+
class ManagedIdentityToken < TokenProvider
|
|
128
|
+
# @return [String] the IMDS token endpoint.
|
|
129
|
+
IMDS_URL = "http://169.254.169.254/metadata/identity/oauth2/token".freeze
|
|
130
|
+
|
|
131
|
+
# @return [String] IMDS API version.
|
|
132
|
+
API_VERSION = "2018-02-01".freeze
|
|
133
|
+
|
|
134
|
+
# @param environment [Environments::Environment]
|
|
135
|
+
# @param client_id [String, nil] the user-assigned identity to use, or
|
|
136
|
+
# nil for the system-assigned identity.
|
|
137
|
+
def initialize(environment:, client_id: nil)
|
|
138
|
+
super(environment:)
|
|
139
|
+
@client_id = client_id
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @return [Array(String, Integer)]
|
|
143
|
+
def fetch_token
|
|
144
|
+
query = { "api-version" => API_VERSION, "resource" => environment.token_audience }
|
|
145
|
+
query["client_id"] = @client_id if @client_id
|
|
146
|
+
|
|
147
|
+
response = Http.request(
|
|
148
|
+
method: :get,
|
|
149
|
+
url: "#{IMDS_URL}?#{URI.encode_www_form(query)}",
|
|
150
|
+
headers: { "Metadata" => "true" }
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
token_from(response, "the instance metadata service")
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Reuses whatever the Azure CLI is already signed in as.
|
|
158
|
+
class AzureCliToken < TokenProvider
|
|
159
|
+
# @return [Array(String, Integer)]
|
|
160
|
+
# @raise [OperationError] if the CLI is missing or not signed in.
|
|
161
|
+
def fetch_token
|
|
162
|
+
stdout, stderr, status = Open3.capture3(
|
|
163
|
+
"az", "account", "get-access-token",
|
|
164
|
+
"--resource", environment.token_audience,
|
|
165
|
+
"--output", "json"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
unless status.success?
|
|
169
|
+
raise OperationError.new("Could not acquire an Azure access token via `az account get-access-token`. Run `az login` first. (#{stderr.strip})")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
payload = JSON.parse(stdout)
|
|
173
|
+
[payload.fetch("accessToken"), cli_expiry(payload)]
|
|
174
|
+
rescue Errno::ENOENT
|
|
175
|
+
raise OperationError.new("The Azure CLI (`az`) was not found on PATH, and no other Azure credentials were configured.")
|
|
176
|
+
rescue JSON::ParserError, KeyError
|
|
177
|
+
raise OperationError.new("Could not understand the response from `az account get-access-token`.")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
# The CLI reports a local timestamp rather than a Unix time.
|
|
183
|
+
#
|
|
184
|
+
# @param payload [Hash]
|
|
185
|
+
# @return [Integer]
|
|
186
|
+
def cli_expiry(payload)
|
|
187
|
+
return payload["expires_on"].to_i if payload["expires_on"].to_s.match?(/\A\d+\z/)
|
|
188
|
+
|
|
189
|
+
Time.parse(payload["expiresOn"]).to_i
|
|
190
|
+
rescue ::StandardError
|
|
191
|
+
Time.now.to_i + 3600
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|