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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2c8f93443204a7cb375624116dbfafaa5ade3419fdd9e0538e1c701f38e60d29
4
- data.tar.gz: e14d346d4cb82139aaaa49fdb9d4ef7f752059e0b535d24285e37a585c5b710b
3
+ metadata.gz: f429ccdaffbdba117dea3b15b9e8beff46b7e5d1e0e1b4e23f0ab2565b652685
4
+ data.tar.gz: cd8ac4c9961e845d121331825a74bc20c85d0317db726f58d5954aedb8b5ffe0
5
5
  SHA512:
6
- metadata.gz: 2474a4e576e924afaa92e121fc315fee4cbce2ab159f946d64d8e208f1127d8dd50fd7d383aafc70beec2bb3dfb9194ddaf79d43c5e4cd1522787a4d38603efa
7
- data.tar.gz: 9e498c0a069a4ee74e7db388c5983df082ba1bf251be06280868abdc802de616f75a93e331b62527a39b839cf55927fcab741265d732eef64f460e34130099e5
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