kitchen-azurerm 2.0.0 → 2.1.2
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/environments.rb +33 -1
- data/lib/kitchen/driver/azure/http.rb +39 -2
- data/lib/kitchen/driver/azure/token_provider.rb +61 -0
- data/lib/kitchen/driver/azure_credentials.rb +93 -19
- data/lib/kitchen/driver/azurerm.rb +119 -30
- data/lib/kitchen/driver/azurerm_version.rb +1 -1
- data/templates/internal.erb +1 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7d34cb889a331fa1622ad6e3729fe3ee619c06b5ab830ccc067ca6e17441be01
|
|
4
|
+
data.tar.gz: a507483fd57dd9ac42ca96e2379d926bd6ad7aa924960374e6bd86298c196f3d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 995c517ffd324584166c51a7463b2bc9588754828c231351224474713b34bc19d0d9ca0714c93fb5c3cdb7d9409911034a7af5a2200ab90fdb89ec3d4f019c74
|
|
7
|
+
data.tar.gz: 4d79344aba476cc4caf04ca35f785a47eeba4de4758d883f26bf0e7bc912c846cc91def32cbdd1fb5325d3bbfe6b9fe6a4adb1bd0c717054283dd6e580f5ed36
|
|
@@ -19,13 +19,45 @@ module Kitchen
|
|
|
19
19
|
# @!attribute [r] token_audience
|
|
20
20
|
# @return [String] audience/resource the access token is requested for.
|
|
21
21
|
Environment = Struct.new(:name, :resource_manager_url, :authentication_endpoint, :token_audience) do
|
|
22
|
-
# The OAuth2 token URL for a tenant in this cloud.
|
|
22
|
+
# The OAuth2 v1.0 token URL for a tenant in this cloud.
|
|
23
23
|
#
|
|
24
24
|
# @param tenant_id [String]
|
|
25
25
|
# @return [String]
|
|
26
26
|
def token_url(tenant_id)
|
|
27
27
|
"#{authentication_endpoint.chomp("/")}/#{tenant_id}/oauth2/token"
|
|
28
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
|
|
29
61
|
end
|
|
30
62
|
|
|
31
63
|
# Every supported cloud, keyed by its downcased name so that lookups are
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
require "ipaddr" unless defined?(IPAddr)
|
|
1
2
|
require "json" unless defined?(JSON)
|
|
2
3
|
require "net/http" unless defined?(Net::HTTP)
|
|
3
4
|
require "openssl" unless defined?(OpenSSL)
|
|
@@ -29,6 +30,11 @@ module Kitchen
|
|
|
29
30
|
OpenSSL::SSL::SSLError,
|
|
30
31
|
].freeze
|
|
31
32
|
|
|
33
|
+
# The link-local range, which holds Azure's instance metadata service.
|
|
34
|
+
#
|
|
35
|
+
# @return [IPAddr]
|
|
36
|
+
LINK_LOCAL = IPAddr.new("169.254.0.0/16").freeze
|
|
37
|
+
|
|
32
38
|
# Seconds to wait for a connection and for a response.
|
|
33
39
|
#
|
|
34
40
|
# @return [Integer]
|
|
@@ -87,11 +93,14 @@ module Kitchen
|
|
|
87
93
|
# @return [Net::HTTPResponse]
|
|
88
94
|
# @api private
|
|
89
95
|
def self.perform(uri, request)
|
|
90
|
-
proxy = uri
|
|
96
|
+
proxy = proxy_for(uri)
|
|
91
97
|
http = if proxy
|
|
92
98
|
Net::HTTP.new(uri.host, uri.port, proxy.host, proxy.port, proxy.user, proxy.password)
|
|
93
99
|
else
|
|
94
|
-
Net::HTTP
|
|
100
|
+
# Explicitly nil rather than Net::HTTP's default of :ENV,
|
|
101
|
+
# which would send it back to the environment to pick a
|
|
102
|
+
# proxy we have just decided against.
|
|
103
|
+
Net::HTTP.new(uri.host, uri.port, nil)
|
|
95
104
|
end
|
|
96
105
|
|
|
97
106
|
http.use_ssl = uri.scheme == "https"
|
|
@@ -100,6 +109,34 @@ module Kitchen
|
|
|
100
109
|
http.start { |connection| connection.request(request) }
|
|
101
110
|
end
|
|
102
111
|
|
|
112
|
+
# The proxy to reach a URL through, if any.
|
|
113
|
+
#
|
|
114
|
+
# Azure's instance metadata service answers on a link-local address,
|
|
115
|
+
# which exists only on the local link and which no proxy can route to.
|
|
116
|
+
# Sending it through one breaks managed identity authentication
|
|
117
|
+
# outright: the connection hangs until the open timeout, surfaces as a
|
|
118
|
+
# {TransientError}, and is then retried. Every Azure SDK carves the
|
|
119
|
+
# same exception out.
|
|
120
|
+
#
|
|
121
|
+
# @param uri [URI]
|
|
122
|
+
# @return [URI, nil]
|
|
123
|
+
# @api private
|
|
124
|
+
def self.proxy_for(uri)
|
|
125
|
+
return nil if link_local?(uri.host)
|
|
126
|
+
|
|
127
|
+
uri.find_proxy
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @param host [String]
|
|
131
|
+
# @return [Boolean] whether the host is a link-local address.
|
|
132
|
+
# @api private
|
|
133
|
+
def self.link_local?(host)
|
|
134
|
+
LINK_LOCAL.include?(IPAddr.new(host.to_s))
|
|
135
|
+
rescue IPAddr::Error
|
|
136
|
+
# Not an IP address at all, so not the metadata service.
|
|
137
|
+
false
|
|
138
|
+
end
|
|
139
|
+
|
|
103
140
|
# @param method [Symbol]
|
|
104
141
|
# @return [Class] the matching +Net::HTTP+ request class.
|
|
105
142
|
# @raise [ArgumentError] for an unsupported method.
|
|
@@ -120,6 +120,67 @@ module Kitchen
|
|
|
120
120
|
end
|
|
121
121
|
end
|
|
122
122
|
|
|
123
|
+
# Authenticates with a federated token issued by another identity
|
|
124
|
+
# provider - GitHub Actions, GitLab, Azure DevOps, or a Kubernetes
|
|
125
|
+
# service account - rather than a stored secret.
|
|
126
|
+
#
|
|
127
|
+
# The platform writes a short-lived signed assertion to a file and
|
|
128
|
+
# rotates it; the file is therefore re-read on every token fetch rather
|
|
129
|
+
# than cached, or a long +kitchen test+ run would start presenting an
|
|
130
|
+
# assertion that has since expired.
|
|
131
|
+
class WorkloadIdentityToken < TokenProvider
|
|
132
|
+
# @return [String] the client assertion type required by Entra ID.
|
|
133
|
+
ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer".freeze
|
|
134
|
+
|
|
135
|
+
# @param environment [Environments::Environment]
|
|
136
|
+
# @param tenant_id [String]
|
|
137
|
+
# @param client_id [String]
|
|
138
|
+
# @param token_file [String] path to the federated token file.
|
|
139
|
+
def initialize(environment:, tenant_id:, client_id:, token_file:)
|
|
140
|
+
super(environment:)
|
|
141
|
+
@tenant_id = tenant_id
|
|
142
|
+
@client_id = client_id
|
|
143
|
+
@token_file = token_file
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @return [String] path to the federated token file.
|
|
147
|
+
attr_reader :token_file
|
|
148
|
+
|
|
149
|
+
# @return [Array(String, Integer)]
|
|
150
|
+
# @raise [OperationError] if the assertion file cannot be read.
|
|
151
|
+
def fetch_token
|
|
152
|
+
response = Http.request(
|
|
153
|
+
method: :post,
|
|
154
|
+
url: environment.token_url_v2(@tenant_id),
|
|
155
|
+
headers: { "Content-Type" => "application/x-www-form-urlencoded" },
|
|
156
|
+
body: URI.encode_www_form(
|
|
157
|
+
grant_type: "client_credentials",
|
|
158
|
+
client_id: @client_id,
|
|
159
|
+
client_assertion_type: ASSERTION_TYPE,
|
|
160
|
+
client_assertion: assertion,
|
|
161
|
+
scope: environment.default_scope
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
token_from(response, "the workload identity token endpoint")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
# The current federated assertion, read fresh each time.
|
|
171
|
+
#
|
|
172
|
+
# @return [String]
|
|
173
|
+
# @raise [OperationError] if the file is missing or unreadable.
|
|
174
|
+
def assertion
|
|
175
|
+
File.read(token_file).strip
|
|
176
|
+
rescue SystemCallError => e
|
|
177
|
+
raise OperationError.new(
|
|
178
|
+
"Could not read the federated token file at #{token_file} (#{e.class}). " \
|
|
179
|
+
"AZURE_FEDERATED_TOKEN_FILE must point at a readable assertion issued by your CI platform."
|
|
180
|
+
)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
123
184
|
# Authenticates as a managed identity, via the Instance Metadata Service.
|
|
124
185
|
#
|
|
125
186
|
# This replaces the legacy MSI extension endpoint on port 50342 that the
|
|
@@ -74,16 +74,24 @@ module Kitchen
|
|
|
74
74
|
|
|
75
75
|
# Endpoints for the configured cloud.
|
|
76
76
|
#
|
|
77
|
+
# +AZURE_AUTHORITY_HOST+ overrides the Entra ID endpoint, which is how
|
|
78
|
+
# platforms that issue federated tokens point at their own authority.
|
|
79
|
+
#
|
|
77
80
|
# @return [Azure::Environments::Environment]
|
|
78
81
|
# @raise [Kitchen::UserError] if the cloud name is not recognised.
|
|
79
82
|
def azure_environment
|
|
80
|
-
@azure_environment ||= Azure::Environments.fetch(environment)
|
|
83
|
+
@azure_environment ||= Azure::Environments.fetch(environment).with_authority(ENV["AZURE_AUTHORITY_HOST"])
|
|
81
84
|
end
|
|
82
85
|
|
|
83
86
|
# Selects a token provider based on which credentials resolved.
|
|
84
87
|
#
|
|
88
|
+
# In precedence order:
|
|
89
|
+
#
|
|
90
|
+
# * +AZURE_FEDERATED_TOKEN_FILE+ + +client_id+ + +tenant_id+ - workload
|
|
91
|
+
# identity federation, the secretless option for CI.
|
|
85
92
|
# * +client_id+ + +client_secret+ + +tenant_id+ - service principal.
|
|
86
|
-
# * +
|
|
93
|
+
# * +AZURE_USE_MSI+ - managed identity, explicitly.
|
|
94
|
+
# * +client_id+ without a secret - user-assigned managed identity.
|
|
87
95
|
# * +tenant_id+ only - system-assigned managed identity.
|
|
88
96
|
# * none of the above - falls back to the +az login+ token cache.
|
|
89
97
|
#
|
|
@@ -104,18 +112,91 @@ module Kitchen
|
|
|
104
112
|
|
|
105
113
|
# @return [Azure::TokenProvider]
|
|
106
114
|
def build_token_provider
|
|
107
|
-
if
|
|
115
|
+
if federated_token_file && client_id && tenant_id
|
|
116
|
+
debug "Authenticating with workload identity federation (#{federated_token_file})."
|
|
117
|
+
Azure::WorkloadIdentityToken.new(environment: azure_environment, tenant_id:, client_id:,
|
|
118
|
+
token_file: federated_token_file)
|
|
119
|
+
elsif client_id && client_secret && tenant_id
|
|
120
|
+
debug "Authenticating as a service principal."
|
|
108
121
|
Azure::ServicePrincipalToken.new(environment: azure_environment, tenant_id:, client_id:, client_secret:)
|
|
109
|
-
elsif
|
|
122
|
+
elsif use_managed_identity?
|
|
123
|
+
debug "Authenticating as a managed identity."
|
|
110
124
|
Azure::ManagedIdentityToken.new(environment: azure_environment, client_id:)
|
|
111
|
-
elsif tenant_id
|
|
125
|
+
elsif tenant_id
|
|
126
|
+
# A tenant with no client credentials means a system-assigned identity.
|
|
127
|
+
debug "Authenticating as a system-assigned managed identity."
|
|
112
128
|
Azure::ManagedIdentityToken.new(environment: azure_environment)
|
|
113
129
|
else
|
|
114
|
-
|
|
130
|
+
warn_about_unusable_credentials
|
|
131
|
+
debug "No Azure credentials were configured; using the token cached by `az login`."
|
|
115
132
|
Azure::AzureCliToken.new(environment: azure_environment)
|
|
116
133
|
end
|
|
117
134
|
end
|
|
118
135
|
|
|
136
|
+
# Warns when credentials were supplied but cannot be used.
|
|
137
|
+
#
|
|
138
|
+
# Reaching the Azure CLI with nothing configured is a supported way to
|
|
139
|
+
# run the driver, so it passes without comment - warning about it on
|
|
140
|
+
# every create and destroy only taught users to ignore the warnings that
|
|
141
|
+
# matter. Reaching it having half-configured a service principal is a
|
|
142
|
+
# mistake worth interrupting for, because the run is about to
|
|
143
|
+
# authenticate as somebody else entirely.
|
|
144
|
+
#
|
|
145
|
+
# @return [void]
|
|
146
|
+
def warn_about_unusable_credentials
|
|
147
|
+
if partial_credentials?
|
|
148
|
+
warn("Incomplete Azure credentials: no #{missing_credentials.join(" or ")} was found in the " \
|
|
149
|
+
"environment or #{config_path}. Falling back to the credentials from `az login`.")
|
|
150
|
+
elsif credentials_file_without_subscription?
|
|
151
|
+
warn("#{config_path} has no [#{subscription_id}] section. " \
|
|
152
|
+
"Falling back to the credentials from `az login`.")
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Whether some, but not enough, client credentials were supplied.
|
|
157
|
+
#
|
|
158
|
+
# @return [Boolean]
|
|
159
|
+
def partial_credentials?
|
|
160
|
+
!(client_id.nil? && client_secret.nil? && federated_token_file.nil?)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The credential values needed to use what was supplied.
|
|
164
|
+
#
|
|
165
|
+
# @return [Array<String>]
|
|
166
|
+
def missing_credentials
|
|
167
|
+
{ "tenant_id" => tenant_id, "client_id" => client_id }.select { |_key, value| value.nil? }.keys
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Whether a credentials file exists but says nothing about the
|
|
171
|
+
# subscription under test. An empty section is deliberate - it is how a
|
|
172
|
+
# user opts one subscription in to the CLI - so it does not count.
|
|
173
|
+
#
|
|
174
|
+
# @return [Boolean]
|
|
175
|
+
def credentials_file_without_subscription?
|
|
176
|
+
File.file?(config_path) && !credentials.has_section?(subscription_id)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Whether to authenticate as a managed identity.
|
|
180
|
+
#
|
|
181
|
+
# A +client_id+ with no accompanying secret means a user-assigned managed
|
|
182
|
+
# identity, which is how the Azure SDKs read the same combination.
|
|
183
|
+
# Crucially this does not require a tenant: the instance metadata service
|
|
184
|
+
# never sees one, so demanding it only prevented the identity from being
|
|
185
|
+
# used at all.
|
|
186
|
+
#
|
|
187
|
+
# @return [Boolean]
|
|
188
|
+
def use_managed_identity?
|
|
189
|
+
return true unless ENV["AZURE_USE_MSI"].to_s.empty?
|
|
190
|
+
|
|
191
|
+
!client_id.nil? && client_secret.nil?
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# @return [String, nil] path to a federated token file, if one is configured.
|
|
195
|
+
def federated_token_file
|
|
196
|
+
value = ENV["AZURE_FEDERATED_TOKEN_FILE"]
|
|
197
|
+
value unless value.to_s.empty?
|
|
198
|
+
end
|
|
199
|
+
|
|
119
200
|
# @return [Kitchen::Logger] the shared Test Kitchen logger.
|
|
120
201
|
def logger
|
|
121
202
|
Kitchen.logger
|
|
@@ -136,25 +217,18 @@ module Kitchen
|
|
|
136
217
|
# Reads a property from the section of the credentials file matching
|
|
137
218
|
# {#subscription_id}.
|
|
138
219
|
#
|
|
220
|
+
# Reads through +to_h+ rather than +IniFile#[]+, which auto-vivifies:
|
|
221
|
+
# asking an +IniFile+ for a section it does not have *adds* that section.
|
|
222
|
+
# Looking up the credentials of an unconfigured subscription therefore
|
|
223
|
+
# used to leave the parsed file claiming to contain it.
|
|
224
|
+
#
|
|
139
225
|
# @param property [String] the INI key to read.
|
|
140
226
|
# @return [String, nil]
|
|
141
227
|
def credentials_property(property)
|
|
142
|
-
value = credentials[subscription_id]&.[](property)
|
|
228
|
+
value = credentials.to_h[subscription_id]&.[](property)
|
|
143
229
|
value unless value.to_s.empty?
|
|
144
230
|
end
|
|
145
231
|
|
|
146
|
-
# Tenant ID, warning the user once when one cannot be resolved.
|
|
147
|
-
#
|
|
148
|
-
# @return [String, nil]
|
|
149
|
-
def tenant_id!
|
|
150
|
-
return tenant_id if tenant_id
|
|
151
|
-
return nil if @warned_about_tenant_id
|
|
152
|
-
|
|
153
|
-
@warned_about_tenant_id = true
|
|
154
|
-
warn("(#{config_path}) does not contain tenant_id neither is the AZURE_TENANT_ID environment variable set.")
|
|
155
|
-
nil
|
|
156
|
-
end
|
|
157
|
-
|
|
158
232
|
# @return [String, nil] tenant ID from the environment or credentials file.
|
|
159
233
|
def tenant_id
|
|
160
234
|
env_or_credentials("AZURE_TENANT_ID", "tenant_id")
|
|
@@ -280,14 +280,8 @@ module Kitchen
|
|
|
280
280
|
|
|
281
281
|
run_deployment(state, "post-deploy", post_deployment(config[:post_deployment_template], config[:post_deployment_parameters])) if File.file?(config[:post_deployment_template])
|
|
282
282
|
rescue Azure::OperationError => operation_error
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
info "Deployment for resource group #{state[:azure_resource_group_name]} is ongoing."
|
|
286
|
-
info "If you need to change the deployment template you'll need to rerun `kitchen create` for this instance."
|
|
287
|
-
else
|
|
288
|
-
info rest_error
|
|
289
|
-
raise operation_error
|
|
290
|
-
end
|
|
283
|
+
info operation_error.body["error"]
|
|
284
|
+
raise operation_error
|
|
291
285
|
end
|
|
292
286
|
|
|
293
287
|
state[:hostname] = resolve_hostname(state, deployment_parameters["nicName"])
|
|
@@ -369,7 +363,18 @@ module Kitchen
|
|
|
369
363
|
def run_deployment(state, prefix, deployment)
|
|
370
364
|
name = "#{prefix}-#{state[:uuid]}"
|
|
371
365
|
info "Creating deployment: #{name}"
|
|
372
|
-
|
|
366
|
+
begin
|
|
367
|
+
create_deployment_async(state[:azure_resource_group_name], name, deployment)
|
|
368
|
+
rescue Azure::OperationError => operation_error
|
|
369
|
+
raise unless operation_error.code == "DeploymentActive"
|
|
370
|
+
|
|
371
|
+
# An interrupted `kitchen create` leaves its deployment running in
|
|
372
|
+
# Azure. Wait for that one instead of abandoning the rest of create:
|
|
373
|
+
# the deployment already in flight is the one we wanted, and the
|
|
374
|
+
# steps after this still have to run for the instance to be usable.
|
|
375
|
+
info "Deployment #{name} is already running; waiting for it rather than submitting it again."
|
|
376
|
+
info "To deploy a changed template, run `kitchen destroy` for this instance first."
|
|
377
|
+
end
|
|
373
378
|
follow_deployment_until_end_state(state[:azure_resource_group_name], name)
|
|
374
379
|
end
|
|
375
380
|
|
|
@@ -458,31 +463,50 @@ module Kitchen
|
|
|
458
463
|
# The VM name to use, either the configured one or one generated from
|
|
459
464
|
# +vm_prefix+ plus part of the instance uuid.
|
|
460
465
|
#
|
|
461
|
-
# The
|
|
462
|
-
# +vm_prefix+ longer than the documented three characters still
|
|
463
|
-
# name Azure will accept.
|
|
466
|
+
# The prefix is capped one character short of {MAX_VM_NAME_LENGTH} so
|
|
467
|
+
# that a +vm_prefix+ longer than the documented three characters still
|
|
468
|
+
# yields a name Azure will accept. Leaving room for at least one uuid
|
|
469
|
+
# character does two things: it keeps some entropy in every generated
|
|
470
|
+
# name, and it guarantees the name ends with one, because a prefix that
|
|
471
|
+
# filled the whole budget could end on the separator it was written
|
|
472
|
+
# with. Azure rejects that outright - both for the VM and for the
|
|
473
|
+
# network interface named after it, which must end with a word
|
|
474
|
+
# character.
|
|
464
475
|
#
|
|
465
476
|
# @param state [Hash] instance state, must already have a +:uuid+.
|
|
466
477
|
# @return [String]
|
|
467
478
|
def generated_vm_name(state)
|
|
468
479
|
return config[:vm_name] if config[:vm_name]
|
|
469
480
|
|
|
470
|
-
prefix = config[:vm_prefix].to_s
|
|
471
|
-
|
|
472
|
-
return prefix[0, MAX_VM_NAME_LENGTH] if remaining <= 0
|
|
473
|
-
|
|
474
|
-
"#{prefix}#{state[:uuid][0, remaining]}"
|
|
481
|
+
prefix = config[:vm_prefix].to_s[0, MAX_VM_NAME_LENGTH - 1]
|
|
482
|
+
"#{prefix}#{state[:uuid][0, MAX_VM_NAME_LENGTH - prefix.length]}"
|
|
475
483
|
end
|
|
476
484
|
|
|
485
|
+
# Maximum length of an Azure resource group name.
|
|
486
|
+
#
|
|
487
|
+
# @return [Integer]
|
|
488
|
+
MAX_RESOURCE_GROUP_NAME_LENGTH = 90
|
|
489
|
+
|
|
477
490
|
# Name of the resource group this instance deploys into.
|
|
478
491
|
#
|
|
492
|
+
# The instance name is the suite and platform joined together, so a
|
|
493
|
+
# descriptive suite on a long platform overruns Azure's limit and
|
|
494
|
+
# +kitchen create+ fails on its very first call - over a name the user
|
|
495
|
+
# never chose. Only that part is shortened: the prefix and suffix were
|
|
496
|
+
# asked for explicitly, and the timestamp is what keeps the name unique.
|
|
497
|
+
#
|
|
479
498
|
# @return [String] +explicit_resource_group_name+ when set, otherwise
|
|
480
499
|
# prefix + instance name + UTC timestamp + suffix.
|
|
481
500
|
def azure_resource_group_name
|
|
482
501
|
return config[:explicit_resource_group_name] if config[:explicit_resource_group_name]
|
|
483
502
|
|
|
484
503
|
formatted_time = Time.now.utc.strftime "%Y%m%dT%H%M%S"
|
|
485
|
-
|
|
504
|
+
prefix = config[:azure_resource_group_prefix].to_s
|
|
505
|
+
suffix = config[:azure_resource_group_suffix].to_s
|
|
506
|
+
room = MAX_RESOURCE_GROUP_NAME_LENGTH - prefix.length - suffix.length - formatted_time.length - 1
|
|
507
|
+
name = config[:azure_resource_group_name].to_s[0, [room, 0].max]
|
|
508
|
+
|
|
509
|
+
"#{prefix}#{name}-#{formatted_time}#{suffix}"
|
|
486
510
|
end
|
|
487
511
|
|
|
488
512
|
# JSON fragment describing the data disks to attach to the VM.
|
|
@@ -663,11 +687,19 @@ module Kitchen
|
|
|
663
687
|
end
|
|
664
688
|
|
|
665
689
|
info "Resource Template deployment reached end state of '#{deployment_provisioning_state}'."
|
|
666
|
-
|
|
690
|
+
return if deployment_provisioning_state == "Succeeded"
|
|
691
|
+
|
|
692
|
+
show_failed_operations(resource_group, deployment_name)
|
|
693
|
+
raise "Deployment '#{deployment_name}' in resource group '#{resource_group}' " \
|
|
694
|
+
"ended in state '#{deployment_provisioning_state}'."
|
|
667
695
|
end
|
|
668
696
|
|
|
669
697
|
# Raises with the status messages of every failed operation in a deployment.
|
|
670
698
|
#
|
|
699
|
+
# Returns quietly when no single operation reported a failure, leaving
|
|
700
|
+
# the caller to raise: a deployment can fail without one, and its own
|
|
701
|
+
# provisioning state is the authority on whether it worked.
|
|
702
|
+
#
|
|
671
703
|
# @param resource_group [String] the resource group name.
|
|
672
704
|
# @param deployment_name [String] the deployment name.
|
|
673
705
|
# @return [void]
|
|
@@ -838,11 +870,20 @@ module Kitchen
|
|
|
838
870
|
|
|
839
871
|
# The full first-boot script handed to a Windows VM as custom data.
|
|
840
872
|
#
|
|
873
|
+
# A Windows VM has exactly one custom data slot, and the driver needs it
|
|
874
|
+
# for the WinRM bootstrap. Any +custom_data+ the user configured has to
|
|
875
|
+
# travel in the same slot, so it is appended here rather than assigned
|
|
876
|
+
# over the top - which is what used to happen, silently discarding it.
|
|
877
|
+
#
|
|
878
|
+
# It runs after WinRM is listening and the data disks are formatted, and
|
|
879
|
+
# before the logoff that ends the first-logon session.
|
|
880
|
+
#
|
|
841
881
|
# @return [String]
|
|
842
882
|
def custom_data_script_windows
|
|
843
883
|
<<-EOH
|
|
844
884
|
#{enable_winrm_powershell_script}
|
|
845
885
|
#{format_data_disks_powershell_script}
|
|
886
|
+
#{custom_data_content}
|
|
846
887
|
logoff
|
|
847
888
|
EOH
|
|
848
889
|
end
|
|
@@ -921,13 +962,30 @@ module Kitchen
|
|
|
921
962
|
info "Using custom vnet: #{config[:vnet_id]}"
|
|
922
963
|
virtual_machine_deployment_template_file("internal.erb", data.merge(
|
|
923
964
|
vnet_id: config[:vnet_id],
|
|
924
|
-
|
|
965
|
+
subnet_ref: subnet_reference,
|
|
925
966
|
public_ip: config[:public_ip],
|
|
926
967
|
public_ip_sku: config[:public_ip_sku]
|
|
927
968
|
))
|
|
928
969
|
end
|
|
929
970
|
end
|
|
930
971
|
|
|
972
|
+
# Resource id of the subnet the network interface attaches to.
|
|
973
|
+
#
|
|
974
|
+
# +subnet_id+ has always held the subnet's *name*, resolved against
|
|
975
|
+
# +vnet_id+. The setting is named like a resource id and sits directly
|
|
976
|
+
# beside +vnet_id+, which really is one, so supplying a full subnet
|
|
977
|
+
# resource id is an easy mistake to make - and it used to be appended to
|
|
978
|
+
# the vnet id, leaving ARM to reject a path that appears nowhere in the
|
|
979
|
+
# user's kitchen.yml. Both spellings are accepted.
|
|
980
|
+
#
|
|
981
|
+
# @return [String]
|
|
982
|
+
def subnet_reference
|
|
983
|
+
subnet = config[:subnet_id].to_s
|
|
984
|
+
return subnet if subnet.start_with?("/subscriptions/")
|
|
985
|
+
|
|
986
|
+
"#{config[:vnet_id]}/subnets/#{subnet}"
|
|
987
|
+
end
|
|
988
|
+
|
|
931
989
|
# Warns about settings that Azure retirements have made inoperable.
|
|
932
990
|
#
|
|
933
991
|
# @return [void]
|
|
@@ -975,9 +1033,33 @@ module Kitchen
|
|
|
975
1033
|
|
|
976
1034
|
# The port(s) the configured transport connects on.
|
|
977
1035
|
#
|
|
978
|
-
#
|
|
1036
|
+
# The transport already knows which port it will dial, so a +port+ set on
|
|
1037
|
+
# it is authoritative: assuming the default instead produced an instance
|
|
1038
|
+
# nothing could reach, and left the user repeating the port in
|
|
1039
|
+
# +open_ports+ to get in.
|
|
1040
|
+
#
|
|
1041
|
+
# WinRM keeps both standard ports regardless, because
|
|
1042
|
+
# {#enable_winrm_powershell_script} creates both listeners whatever the
|
|
1043
|
+
# transport was pointed at.
|
|
1044
|
+
#
|
|
1045
|
+
# @return [Array<Integer>]
|
|
979
1046
|
def transport_ports
|
|
980
|
-
instance.transport
|
|
1047
|
+
configured = instance.transport[:port].to_i
|
|
1048
|
+
|
|
1049
|
+
if winrm_transport?
|
|
1050
|
+
([5985, 5986] + [configured]).reject(&:zero?).uniq
|
|
1051
|
+
elsif configured == 0
|
|
1052
|
+
[22]
|
|
1053
|
+
else
|
|
1054
|
+
[configured]
|
|
1055
|
+
end
|
|
1056
|
+
end
|
|
1057
|
+
|
|
1058
|
+
# Whether the instance is driven over WinRM.
|
|
1059
|
+
#
|
|
1060
|
+
# @return [Boolean]
|
|
1061
|
+
def winrm_transport?
|
|
1062
|
+
instance.transport.name.to_s.casecmp("winrm") == 0
|
|
981
1063
|
end
|
|
982
1064
|
|
|
983
1065
|
# ARM security rules for the generated network security group.
|
|
@@ -1034,18 +1116,25 @@ module Kitchen
|
|
|
1034
1116
|
|
|
1035
1117
|
# Base64-encoded custom data for the VM.
|
|
1036
1118
|
#
|
|
1037
|
-
# +custom_data+ may be either the literal content or a path to a file
|
|
1038
|
-
# holding it.
|
|
1039
|
-
#
|
|
1040
1119
|
# @return [String, nil] nil when no +custom_data+ is configured.
|
|
1041
1120
|
def prepared_custom_data
|
|
1042
1121
|
return nil if config[:custom_data].nil?
|
|
1043
1122
|
|
|
1044
|
-
@prepared_custom_data ||=
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1123
|
+
@prepared_custom_data ||= Base64.strict_encode64(custom_data_content)
|
|
1124
|
+
end
|
|
1125
|
+
|
|
1126
|
+
# The configured custom data, as content.
|
|
1127
|
+
#
|
|
1128
|
+
# +custom_data+ may be either the literal content or a path to a file
|
|
1129
|
+
# holding it, so this resolves whichever was given.
|
|
1130
|
+
#
|
|
1131
|
+
# @return [String] empty when no +custom_data+ is configured.
|
|
1132
|
+
def custom_data_content
|
|
1133
|
+
@custom_data_content ||= if readable_file?(config[:custom_data])
|
|
1134
|
+
File.read(config[:custom_data])
|
|
1135
|
+
else
|
|
1136
|
+
config[:custom_data].to_s
|
|
1137
|
+
end
|
|
1049
1138
|
end
|
|
1050
1139
|
|
|
1051
1140
|
private
|
data/templates/internal.erb
CHANGED
|
@@ -176,13 +176,12 @@
|
|
|
176
176
|
"location": "[parameters('location')]",
|
|
177
177
|
"nicName": "[parameters('nicName')]",
|
|
178
178
|
"nsgName": "[concat(parameters('nicName'), '-nsg')]",
|
|
179
|
-
"subnetName": "<%= subnet_id %>",
|
|
180
179
|
"publicIPAddressName": "publicip",
|
|
181
180
|
"vmName": "[parameters('vmName')]",
|
|
182
181
|
"vmSize": "[parameters('vmSize')]",
|
|
183
182
|
"vmIdentityType": "[if(parameters('systemAssignedIdentity'), if(empty(parameters('userAssignedIdentities')), 'SystemAssigned', 'SystemAssigned, UserAssigned'), if(empty(parameters('userAssignedIdentities')), 'None', 'UserAssigned'))]",
|
|
184
183
|
"vnetID": "<%= vnet_id %>",
|
|
185
|
-
"subnetRef": "
|
|
184
|
+
"subnetRef": "<%= subnet_ref %>"
|
|
186
185
|
},
|
|
187
186
|
"resources": [
|
|
188
187
|
{
|