kitchen-azurerm 2.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f429ccdaffbdba117dea3b15b9e8beff46b7e5d1e0e1b4e23f0ab2565b652685
4
- data.tar.gz: cd8ac4c9961e845d121331825a74bc20c85d0317db726f58d5954aedb8b5ffe0
3
+ metadata.gz: 7d34cb889a331fa1622ad6e3729fe3ee619c06b5ab830ccc067ca6e17441be01
4
+ data.tar.gz: a507483fd57dd9ac42ca96e2379d926bd6ad7aa924960374e6bd86298c196f3d
5
5
  SHA512:
6
- metadata.gz: 03a1ed78caa550f245b466c9cfb218dda27d45f50e2b158371e4b14b9f1d45e30d478c277a0ccf437a00855d8dd2f8b8d883d51578b66d750d51c355859d1073
7
- data.tar.gz: 181aa2863481530c6d2d78d2d050918dedbc71f51723928eb166dd8ae78d7dd966090f5a112a6202b74d160fba62cf695b29e86335853038672dc342e8577628
6
+ metadata.gz: 995c517ffd324584166c51a7463b2bc9588754828c231351224474713b34bc19d0d9ca0714c93fb5c3cdb7d9409911034a7af5a2200ab90fdb89ec3d4f019c74
7
+ data.tar.gz: 4d79344aba476cc4caf04ca35f785a47eeba4de4758d883f26bf0e7bc912c846cc91def32cbdd1fb5325d3bbfe6b9fe6a4adb1bd0c717054283dd6e580f5ed36
@@ -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.find_proxy
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.new(uri.host, uri.port)
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.
@@ -112,23 +112,70 @@ module Kitchen
112
112
 
113
113
  # @return [Azure::TokenProvider]
114
114
  def build_token_provider
115
- if federated_token_file && client_id && tenant_id!
115
+ if federated_token_file && client_id && tenant_id
116
116
  debug "Authenticating with workload identity federation (#{federated_token_file})."
117
117
  Azure::WorkloadIdentityToken.new(environment: azure_environment, tenant_id:, client_id:,
118
118
  token_file: federated_token_file)
119
- elsif client_id && client_secret && tenant_id!
119
+ elsif client_id && client_secret && tenant_id
120
+ debug "Authenticating as a service principal."
120
121
  Azure::ServicePrincipalToken.new(environment: azure_environment, tenant_id:, client_id:, client_secret:)
121
122
  elsif use_managed_identity?
123
+ debug "Authenticating as a managed identity."
122
124
  Azure::ManagedIdentityToken.new(environment: azure_environment, client_id:)
123
- elsif tenant_id!
125
+ elsif tenant_id
124
126
  # A tenant with no client credentials means a system-assigned identity.
127
+ debug "Authenticating as a system-assigned managed identity."
125
128
  Azure::ManagedIdentityToken.new(environment: azure_environment)
126
129
  else
127
- warn("Using tenant id set through `az login`.")
130
+ warn_about_unusable_credentials
131
+ debug "No Azure credentials were configured; using the token cached by `az login`."
128
132
  Azure::AzureCliToken.new(environment: azure_environment)
129
133
  end
130
134
  end
131
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
+
132
179
  # Whether to authenticate as a managed identity.
133
180
  #
134
181
  # A +client_id+ with no accompanying secret means a user-assigned managed
@@ -170,25 +217,18 @@ module Kitchen
170
217
  # Reads a property from the section of the credentials file matching
171
218
  # {#subscription_id}.
172
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
+ #
173
225
  # @param property [String] the INI key to read.
174
226
  # @return [String, nil]
175
227
  def credentials_property(property)
176
- value = credentials[subscription_id]&.[](property)
228
+ value = credentials.to_h[subscription_id]&.[](property)
177
229
  value unless value.to_s.empty?
178
230
  end
179
231
 
180
- # Tenant ID, warning the user once when one cannot be resolved.
181
- #
182
- # @return [String, nil]
183
- def tenant_id!
184
- return tenant_id if tenant_id
185
- return nil if @warned_about_tenant_id
186
-
187
- @warned_about_tenant_id = true
188
- warn("(#{config_path}) does not contain tenant_id neither is the AZURE_TENANT_ID environment variable set.")
189
- nil
190
- end
191
-
192
232
  # @return [String, nil] tenant ID from the environment or credentials file.
193
233
  def tenant_id
194
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
- rest_error = operation_error.body["error"]
284
- if operation_error.code == "DeploymentActive"
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
- create_deployment_async(state[:azure_resource_group_name], name, deployment)
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 generated name is truncated to {MAX_VM_NAME_LENGTH} so that a
462
- # +vm_prefix+ longer than the documented three characters still yields a
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
- remaining = MAX_VM_NAME_LENGTH - prefix.length
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
- "#{config[:azure_resource_group_prefix]}#{config[:azure_resource_group_name]}-#{formatted_time}#{config[:azure_resource_group_suffix]}"
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
- show_failed_operations(resource_group, deployment_name) if deployment_provisioning_state == "Failed"
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
- subnet_id: config[:subnet_id],
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
- # @return [Array<Integer>] 5985 and 5986 for WinRM, otherwise 22.
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.name.to_s.casecmp("winrm") == 0 ? [5985, 5986] : [22]
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 ||= if readable_file?(config[:custom_data])
1045
- Base64.strict_encode64(File.read(config[:custom_data]))
1046
- else
1047
- Base64.strict_encode64(config[:custom_data])
1048
- end
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
@@ -6,6 +6,6 @@ module Kitchen
6
6
  # driver and, with it, the whole Azure SDK.
7
7
  #
8
8
  # @return [String]
9
- AZURERM_VERSION = "2.1.0".freeze
9
+ AZURERM_VERSION = "2.1.2".freeze
10
10
  end
11
11
  end
@@ -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": "[concat(variables('vnetID'),'/subnets/',variables('subnetName'))]"
184
+ "subnetRef": "<%= subnet_ref %>"
186
185
  },
187
186
  "resources": [
188
187
  {
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kitchen-azurerm
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.0
4
+ version: 2.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stuart Preston