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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d35fbe96cb5105531f593d3c43343de784274f46e1efe095c399074e8cb8f8f2
4
- data.tar.gz: 7c5deaf7be58a437b19244d6dfa70358c1d1e6ea218f9d8b0efda28e92ca0a8f
3
+ metadata.gz: '0853e2f879329c6b0664e4931574d1ca3a8b8998fc1a5e56424eab212b2f95b1'
4
+ data.tar.gz: e00246cb4d2f291492df022874e9755fb6dc7745067428dd2f1ac291e57fc32f
5
5
  SHA512:
6
- metadata.gz: 94b15b90e66ee2d5b83ee65ca5087a5523fca00bc938d95a7d6979e3b69eb337b8b0f94aad553d348dd14df0941cc83082190dbf772fe29e923940f945893af8
7
- data.tar.gz: bf15ffcb819adf6b1d71eb520391d0b39a8eb899d2df01ee4e304032fdef96858b389001de7f0d0559e71f0018eb42fddd8a2b0d766df16d7965da6220c9a41d
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