kitchen-azurerm 2.1.3 → 2.3.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: 4b4d1f1b3015fb567bd97165402be78a03a899e5388e021cfd83494fef6c80a3
4
- data.tar.gz: c457612130887cb6a4cc3762bda8b5ee2f3b0abb64740414affb619081e88a45
3
+ metadata.gz: fbc95dc73706d3eb94349e23d021b1747f838b1dde2ea375d3be1fe2d27b920d
4
+ data.tar.gz: 6719ba4591a556c5eeb6a1187d21fddd5ad2b72e4faf74b8fc5d483158c09ad1
5
5
  SHA512:
6
- metadata.gz: c289785b2a403744122fe84837c54c63c1946e9e7c1ae8819add99ab27cdb3810415e98951dec0e7165ad0548d98d86064269e385974ed2fbcfa4c9b038a8750
7
- data.tar.gz: 7d48bc3cfc134dd5ede4b6e58f116efd13e59ff4d1a95b26f7dcd7c79172518a52f1e5503395bf50d8538312c0caf0f8180390559f37ff96da8153ba6e9072b1
6
+ metadata.gz: fbda874b618647705e59cd47b1ae9ef4f128b7bdfacebef707824eabc158f875b011ae666c33b5e6bff478553af14fc9206f7c23bcc41b0955c58f3b6b57fc70
7
+ data.tar.gz: 654610bdad5017605ece2dbede6907952b605e7607f5024a8e15a6f3e786af8b69ef8b19dfab78f79898afe945cf4415f5600806c1bbedf4edc8b2125b845828
@@ -25,6 +25,18 @@ module Kitchen
25
25
  # @return [String]
26
26
  NETWORK_API_VERSION = "2025-07-01".freeze
27
27
 
28
+ # ARM API version used for compute resource requests. Matches the
29
+ # version the bundled deployment templates declare for virtual
30
+ # machines.
31
+ #
32
+ # @return [String]
33
+ COMPUTE_API_VERSION = "2025-04-01".freeze
34
+
35
+ # ARM API version used to read the subscription itself.
36
+ #
37
+ # @return [String]
38
+ SUBSCRIPTION_API_VERSION = "2022-12-01".freeze
39
+
28
40
  # @param subscription_id [String]
29
41
  # @param environment [Environments::Environment]
30
42
  # @param token_provider [TokenProvider]
@@ -43,6 +55,19 @@ module Kitchen
43
55
  # @return [TokenProvider]
44
56
  attr_reader :token_provider
45
57
 
58
+ # Reads the subscription itself.
59
+ #
60
+ # Unlike a resource group HEAD - which answers 404 both for a
61
+ # subscription that does not exist and for one that merely has no such
62
+ # group - this distinguishes a reachable subscription from a wrong
63
+ # one, so it is what {Azurerm#doctor} probes with.
64
+ #
65
+ # @return [Hash]
66
+ def subscription
67
+ call(:get, "/subscriptions/#{escape(subscription_id)}",
68
+ api_version: SUBSCRIPTION_API_VERSION).json
69
+ end
70
+
46
71
  # Whether a resource group exists.
47
72
  #
48
73
  # @param name [String] resource group name, case-insensitive.
@@ -67,10 +92,18 @@ module Kitchen
67
92
  # Requests deletion of a resource group, returning as soon as ARM
68
93
  # accepts the request rather than waiting for it to finish.
69
94
  #
95
+ # A group that is already gone counts as deleted: that is the outcome
96
+ # the caller asked for, and ARM answers 404. Treating that as an error
97
+ # wedged +kitchen destroy+ permanently for any instance whose resource
98
+ # group had been removed out of band - deleted in the portal to save
99
+ # money, or never created because +kitchen create+ failed early. Every
100
+ # subsequent destroy failed the same way, so the instance could only be
101
+ # cleared by hand-deleting its state file.
102
+ #
70
103
  # @param name [String] resource group name.
71
104
  # @return [void]
72
105
  def delete_resource_group(name)
73
- call(:delete, resource_group_path(name), api_version: RESOURCES_API_VERSION)
106
+ call(:delete, resource_group_path(name), api_version: RESOURCES_API_VERSION, allow: [404])
74
107
  nil
75
108
  end
76
109
 
@@ -107,6 +140,17 @@ module Kitchen
107
140
  payload.is_a?(Hash) ? Array(payload["value"]) : []
108
141
  end
109
142
 
143
+ # Reads a virtual machine's instance view, which carries its current
144
+ # power state.
145
+ #
146
+ # @param resource_group [String]
147
+ # @param name [String] virtual machine name.
148
+ # @return [Hash]
149
+ def virtual_machine_instance_view(resource_group, name)
150
+ call(:get, "#{compute_path(resource_group, "virtualMachines", name)}/instanceView",
151
+ api_version: COMPUTE_API_VERSION).json
152
+ end
153
+
110
154
  # Reads a public IP address resource.
111
155
  #
112
156
  # @param resource_group [String]
@@ -150,6 +194,14 @@ module Kitchen
150
194
  "#{resource_group_path(resource_group)}/providers/Microsoft.Network/#{type}/#{escape(name)}"
151
195
  end
152
196
 
197
+ # @param resource_group [String]
198
+ # @param type [String] e.g. +"virtualMachines"+.
199
+ # @param name [String]
200
+ # @return [String]
201
+ def compute_path(resource_group, type, name)
202
+ "#{resource_group_path(resource_group)}/providers/Microsoft.Compute/#{type}/#{escape(name)}"
203
+ end
204
+
153
205
  # @param value [String]
154
206
  # @return [String] path-escaped
155
207
  def escape(value)
@@ -40,6 +40,17 @@ module Kitchen
40
40
  def code
41
41
  body.is_a?(Hash) ? body.dig("error", "code") : nil
42
42
  end
43
+
44
+ # Azure's own explanation, when ARM supplied one.
45
+ #
46
+ # +message+ is this class's summary of the request that failed; this
47
+ # is what Azure said about it, which is usually the part worth showing
48
+ # somebody.
49
+ #
50
+ # @return [String, nil]
51
+ def detail
52
+ body.is_a?(Hash) ? body.dig("error", "message") : nil
53
+ end
43
54
  end
44
55
 
45
56
  # Raised when a request could not be completed and is worth retrying:
@@ -66,8 +66,12 @@ module Kitchen
66
66
 
67
67
  # Ubuntu 22.04 LTS, generation 2. Canonical renamed their offers after
68
68
  # 18.04, so the old "UbuntuServer" offer no longer resolves at all.
69
+ #
70
+ # @return [String]
71
+ DEFAULT_IMAGE_URN = "Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:latest".freeze
72
+
69
73
  default_config(:image_urn) do |_config|
70
- "Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:latest"
74
+ DEFAULT_IMAGE_URN
71
75
  end
72
76
 
73
77
  default_config(:image_id) do |_config|
@@ -273,12 +277,12 @@ module Kitchen
273
277
  end
274
278
 
275
279
  begin
276
- run_deployment(state, "pre-deploy", pre_deployment(config[:pre_deployment_template], config[:pre_deployment_parameters])) if File.file?(config[:pre_deployment_template])
280
+ run_deployment(state, "pre-deploy", pre_deployment(config[:pre_deployment_template], config[:pre_deployment_parameters])) if File.file?(config[:pre_deployment_template].to_s)
277
281
 
278
282
  run_deployment(state, "deploy", deployment(deployment_parameters))
279
283
  store_deployment_credentials(state, deployment_parameters)
280
284
 
281
- run_deployment(state, "post-deploy", post_deployment(config[:post_deployment_template], config[:post_deployment_parameters])) if File.file?(config[:post_deployment_template])
285
+ run_deployment(state, "post-deploy", post_deployment(config[:post_deployment_template], config[:post_deployment_parameters])) if File.file?(config[:post_deployment_template].to_s)
282
286
  rescue Azure::OperationError => operation_error
283
287
  info operation_error.body["error"]
284
288
  raise operation_error
@@ -287,6 +291,78 @@ module Kitchen
287
291
  state[:hostname] = resolve_hostname(state, deployment_parameters["nicName"])
288
292
  end
289
293
 
294
+ # Settings the driver cannot supply a default for, and what they are.
295
+ #
296
+ # @return [Hash{Symbol => String}]
297
+ REQUIRED_CONFIG = {
298
+ subscription_id: "the Azure subscription to deploy into",
299
+ location: "the Azure region to deploy into, e.g. eastus",
300
+ machine_size: "the VM size to deploy, e.g. Standard_D2s_v3",
301
+ }.freeze
302
+
303
+ # Checks configuration and credentials, for +kitchen doctor+.
304
+ #
305
+ # Deployment failures are usually one of two things: a required setting
306
+ # nobody filled in, or credentials that do not work. Both otherwise
307
+ # surface as an Azure error partway through a create, once the resource
308
+ # group already exists, so this looks for them up front.
309
+ #
310
+ # @param state [Hash] the instance state, unused - the checks are about
311
+ # configuration and credentials, which exist before any instance does.
312
+ # @return [Boolean] true when a problem was found, as +kitchen doctor+
313
+ # expects.
314
+ def doctor(_state)
315
+ problems = missing_required_config
316
+ problems += unreachable_azure if problems.empty?
317
+ problems.each { |problem| error("kitchen-azurerm: #{problem}") }
318
+ problems.any?
319
+ end
320
+
321
+ # Required settings that were left unset.
322
+ #
323
+ # @return [Array<String>]
324
+ def missing_required_config
325
+ REQUIRED_CONFIG.select { |option, _| config[option].to_s.empty? }
326
+ .map { |option, purpose| "#{option} is not set. It has no default: give it #{purpose}." }
327
+ end
328
+
329
+ # Whether Azure answers, accepts the credentials, and knows the
330
+ # subscription.
331
+ #
332
+ # Reads the subscription rather than probing a resource group: ARM
333
+ # answers a resource group HEAD with 404 both for a subscription that
334
+ # does not exist and for one that merely has no such group, so it cannot
335
+ # tell a wrong subscription from a healthy one. Reading the subscription
336
+ # gives back either its details or +SubscriptionNotFound+.
337
+ #
338
+ # @return [Array<String>]
339
+ def unreachable_azure
340
+ Kitchen::Driver::AzureCredentials.new(subscription_id: config[:subscription_id],
341
+ environment: config[:azure_environment]).arm_client.subscription
342
+ []
343
+ rescue Azure::OperationError => operation_error
344
+ [azure_problem(operation_error)]
345
+ rescue Azure::TransientError => transient_error
346
+ ["Could not reach Azure (#{transient_error.message})."]
347
+ end
348
+
349
+ # Describes an OperationError raised while checking Azure.
350
+ #
351
+ # An error carrying no Azure error code never reached ARM - it comes
352
+ # from acquiring the token, and already explains itself. Announcing that
353
+ # Azure rejected a request that was never made only sends the reader
354
+ # looking in the wrong place.
355
+ #
356
+ # @param operation_error [Azure::OperationError]
357
+ # @return [String]
358
+ def azure_problem(operation_error)
359
+ return operation_error.message unless operation_error.code
360
+
361
+ "Azure rejected the request (#{operation_error.code}: " \
362
+ "#{operation_error.detail || operation_error.message}). " \
363
+ "Check the credentials, and that they can reach subscription #{config[:subscription_id]}."
364
+ end
365
+
290
366
  # Builds the ARM parameter values for the virtual machine deployment.
291
367
  #
292
368
  # @param state [Hash] instance state, already through {#validate_state}.
@@ -315,7 +391,7 @@ module Kitchen
315
391
 
316
392
  parameters["nicName"] = nic_name(state)
317
393
  parameters["customData"] = prepared_custom_data unless config[:custom_data].to_s.empty?
318
- parameters["osDiskSizeGb"] = config[:os_disk_size_gb] unless config[:os_disk_size_gb].to_s.empty?
394
+ parameters["osDiskSizeGb"] = os_disk_size_gb unless config[:os_disk_size_gb].to_s.empty?
319
395
  parameters["nsgId"] = config[:nsg_id] unless config[:nsg_id].to_s.empty?
320
396
 
321
397
  parameters.merge(image_parameters)
@@ -329,10 +405,45 @@ module Kitchen
329
405
  def image_parameters
330
406
  return { "imageId" => config[:image_id] } if config[:image_id].to_s != ""
331
407
 
332
- publisher, offer, sku, version = config[:image_urn].split(":", 4)
408
+ publisher, offer, sku, version = image_urn.split(":", 4)
333
409
  { "imagePublisher" => publisher, "imageOffer" => offer, "imageSku" => sku, "imageVersion" => version }
334
410
  end
335
411
 
412
+ # The Marketplace image URN to deploy from.
413
+ #
414
+ # An +image_urn:+ written with no value is nil rather than "", which
415
+ # counts as unset here the way it does everywhere else in the driver -
416
+ # so it falls back to {DEFAULT_IMAGE_URN} rather than failing with a
417
+ # Ruby error naming neither the setting nor the file it came from.
418
+ #
419
+ # @return [String]
420
+ def image_urn
421
+ urn = config[:image_urn].to_s
422
+ urn.empty? ? DEFAULT_IMAGE_URN : urn
423
+ end
424
+
425
+ # The OS disk size, as a number ARM will accept.
426
+ #
427
+ # YAML makes +os_disk_size_gb: 64+ an Integer and +os_disk_size_gb: "64"+
428
+ # a String, and the ARM parameter is typed +int+ - Azure rejects the
429
+ # String outright rather than coercing it:
430
+ #
431
+ # The provided value for the template parameter 'osDiskSizeGb' is not
432
+ # valid. Expected a value of type 'Integer', but received a value of
433
+ # type 'String'.
434
+ #
435
+ # Quoting a number in kitchen.yml is an easy thing to do, and the driver
436
+ # already tolerates the same ambiguity for +boot_diagnostics_enabled+.
437
+ #
438
+ # @return [Integer]
439
+ # @raise [Kitchen::UserError] if the value is not a whole number.
440
+ def os_disk_size_gb
441
+ Integer(config[:os_disk_size_gb])
442
+ rescue ArgumentError, TypeError
443
+ raise Kitchen::UserError,
444
+ "os_disk_size_gb must be a whole number of gigabytes, but was #{config[:os_disk_size_gb].inspect}."
445
+ end
446
+
336
447
  # Whether managed boot diagnostics should be switched on.
337
448
  #
338
449
  # Historically this setting defaulted to the *string* +"true"+, and plenty
@@ -429,11 +540,20 @@ module Kitchen
429
540
 
430
541
  # Whether a state property is already populated.
431
542
  #
543
+ # A blank value does not count. A run that fails early still writes its
544
+ # state, so an empty +subscription_id+ - which is what a quoted ERB
545
+ # interpolation of an unset variable produces - used to be taken as
546
+ # authoritative from then on, shadowing the real value in config for the
547
+ # rest of the instance's life.
548
+ #
549
+ # +false+ is a value, so this asks whether the value is blank rather
550
+ # than whether it is truthy.
551
+ #
432
552
  # @param state [Hash] Hash of existing state values.
433
553
  # @param property [Symbol, String] the property to check.
434
- # @return [Boolean] true when the key exists and its value is not nil.
554
+ # @return [Boolean] true when the key exists and its value is not blank.
435
555
  def existing_state_value?(state, property)
436
- state.key?(property) && !state[property].nil?
556
+ state.key?(property) && !state[property].to_s.empty?
437
557
  end
438
558
 
439
559
  # Fills in any state values that are not already present.
@@ -561,26 +681,79 @@ module Kitchen
561
681
  template["resources"].select { |resource| resource["type"] == "Microsoft.Compute/virtualMachines" }
562
682
  end
563
683
 
684
+ # Serializes the generate-and-write below.
685
+ #
686
+ # Test Kitchen runs instances as threads inside one process, so
687
+ # +kitchen create -c+ had every thread reach the "does the key exist?"
688
+ # check before any of them had written one. Each generated its own pair
689
+ # and wrote it over the last, so only the instance whose key happened to
690
+ # land on disk last was reachable - the rest were deployed with a public
691
+ # key nobody held the private half of, and failed at converge with
692
+ # +Permission denied (publickey)+.
693
+ #
694
+ # @return [Mutex]
695
+ KEY_GENERATION_MUTEX = Mutex.new
696
+
564
697
  # Returns the public key to inject into the deployment, generating a new
565
698
  # key pair on disk when the configured private key does not yet exist.
566
699
  #
567
700
  # @param private_key_filename [String] path to the transport's private key.
568
701
  # @return [String] the OpenSSH-format public key, stripped of whitespace.
569
702
  def public_key_for_deployment(private_key_filename)
570
- unless File.file?(private_key_filename)
571
- key = SSHKey.generate
703
+ KEY_GENERATION_MUTEX.synchronize do
704
+ generate_key_pair(private_key_filename) unless File.file?(private_key_filename)
572
705
 
573
- ::FileUtils.mkdir_p(File.dirname(private_key_filename))
574
- File.write(private_key_filename, key.private_key)
575
- File.chmod(0600, private_key_filename)
576
- File.write("#{private_key_filename}.pub", key.ssh_public_key)
577
- File.chmod(0600, "#{private_key_filename}.pub")
706
+ explicit = instance.transport[:ssh_public_key]
707
+ public_key_filename = explicit || "#{private_key_filename}.pub"
708
+ unless File.file?(public_key_filename)
709
+ raise Kitchen::UserError, missing_public_key_message(private_key_filename, public_key_filename, explicit)
710
+ end
578
711
 
579
- return key.ssh_public_key.strip
712
+ File.read(public_key_filename).strip
580
713
  end
714
+ end
581
715
 
582
- public_key_filename = instance.transport[:ssh_public_key] || "#{private_key_filename}.pub"
583
- File.read(public_key_filename).strip
716
+ # Writes a fresh SSH key pair to disk.
717
+ #
718
+ # Always called with {KEY_GENERATION_MUTEX} held.
719
+ #
720
+ # @param private_key_filename [String] path to write the private key to.
721
+ # @return [void]
722
+ def generate_key_pair(private_key_filename)
723
+ key = SSHKey.generate
724
+
725
+ ::FileUtils.mkdir_p(File.dirname(private_key_filename))
726
+ File.write(private_key_filename, key.private_key)
727
+ File.chmod(0600, private_key_filename)
728
+ File.write("#{private_key_filename}.pub", key.ssh_public_key)
729
+ File.chmod(0600, "#{private_key_filename}.pub")
730
+ end
731
+
732
+ # Explains that the public half of the transport's key could not be found.
733
+ #
734
+ # +ssh_key+ names the *private* key, so a user who keeps only that has
735
+ # nothing wrong with their kitchen.yml. They used to get a raw
736
+ # Errno::ENOENT for a ".pub" path they never wrote, with nothing to say
737
+ # where it came from or what to do about it.
738
+ #
739
+ # The key cannot simply be derived: sshkey only reads PEM-encoded RSA,
740
+ # while ssh-keygen has emitted the OpenSSH format by default since 7.8
741
+ # and ed25519 keys are never PEM. +ssh-keygen -y+ handles every format,
742
+ # so point at that instead.
743
+ #
744
+ # @param private_key_filename [String] the configured private key.
745
+ # @param public_key_filename [String] where the public key was looked for.
746
+ # @param explicit [String, nil] +ssh_public_key+, when the user set it.
747
+ # @return [String]
748
+ def missing_public_key_message(private_key_filename, public_key_filename, explicit)
749
+ if explicit
750
+ "The transport's ssh_public_key setting points at #{public_key_filename}, which does not exist. " \
751
+ "Correct the path, or remove the setting to use #{private_key_filename}.pub."
752
+ else
753
+ "No public key was found at #{public_key_filename}. The transport's ssh_key setting names the " \
754
+ "private key, and the public half of it has to go on the virtual machine. Create it with: " \
755
+ "ssh-keygen -y -f #{private_key_filename} > #{public_key_filename}"
756
+ end
584
757
  end
585
758
 
586
759
  # Builds the pre-deployment from a caller-supplied ARM template file.
@@ -729,6 +902,50 @@ module Kitchen
729
902
  end
730
903
  end
731
904
 
905
+ # How Azure's power states map onto Test Kitchen's liveness question.
906
+ #
907
+ # Anything absent from this table is passed through as-is with +live+
908
+ # left nil: reporting a state we have not seen before is more use than
909
+ # guessing whether it counts as running.
910
+ #
911
+ # @return [Hash{String => Boolean}]
912
+ POWER_STATES = {
913
+ "running" => true,
914
+ "starting" => true,
915
+ "stopping" => true,
916
+ "stopped" => false,
917
+ "deallocating" => false,
918
+ "deallocated" => false,
919
+ }.freeze
920
+
921
+ # Reports whether the virtual machine backing this instance is running.
922
+ #
923
+ # Drives +kitchen list --live+ (and its +kitchen status+ alias). Test
924
+ # Kitchen rescues anything raised here and reports "unknown", but a
925
+ # listing should not be where an Azure outage first shows up, so the
926
+ # failure modes are handled explicitly and reported as data.
927
+ #
928
+ # No retries: this is a status probe behind an interactive command, and
929
+ # waiting out the retry budget on every unreachable instance would be
930
+ # worse than saying so promptly.
931
+ #
932
+ # @param state [Hash] the instance state.
933
+ # @return [Hash] +:live+, +:state+, +:resource_id+ and +:message+.
934
+ def status(state)
935
+ resource_group = state[:azure_resource_group_name]
936
+ vm_name = state[:vm_name]
937
+ return { live: false, state: "not_created", message: "No Azure virtual machine has been created yet." } unless
938
+ state[:server_id] && resource_group && vm_name
939
+
940
+ power_state_status(state, resource_group, vm_name)
941
+ rescue Azure::OperationError => operation_error
942
+ return { live: false, state: "not_created", message: "The virtual machine no longer exists in Azure." } if operation_error.status == 404
943
+
944
+ { live: nil, state: "unknown", message: "#{operation_error.code}: #{operation_error.message}" }
945
+ rescue Azure::TransientError => transient_error
946
+ { live: nil, state: "unknown", message: "Could not reach Azure (#{transient_error.message})." }
947
+ end
948
+
732
949
  # Tears down whatever {#create} built.
733
950
  #
734
951
  # @param state [Hash] the instance state, mutated in place.
@@ -736,8 +953,8 @@ module Kitchen
736
953
  # @raise [Azure::OperationError] if an Azure API call fails.
737
954
  def destroy(state)
738
955
  # TODO: We have some not so fun state issues we need to clean up
739
- state[:azure_environment] = config[:azure_environment] unless state[:azure_environment]
740
- state[:subscription_id] = config[:subscription_id] unless state[:subscription_id]
956
+ state[:azure_environment] = config[:azure_environment] unless existing_state_value?(state, :azure_environment)
957
+ state[:subscription_id] = config[:subscription_id] unless existing_state_value?(state, :subscription_id)
741
958
 
742
959
  @arm_client = Kitchen::Driver::AzureCredentials.new(subscription_id: state[:subscription_id],
743
960
  environment: state[:azure_environment]).arm_client
@@ -773,6 +990,53 @@ module Kitchen
773
990
  state.delete(:password)
774
991
  end
775
992
 
993
+ # Asks Azure for the virtual machine's power state.
994
+ #
995
+ # @param state [Hash] the instance state.
996
+ # @param resource_group [String]
997
+ # @param vm_name [String]
998
+ # @return [Hash] as {#status} returns.
999
+ def power_state_status(state, resource_group, vm_name)
1000
+ view = status_arm_client(state).virtual_machine_instance_view(resource_group, vm_name)
1001
+ status = Array(view["statuses"]).find { |entry| entry["code"].to_s.start_with?("PowerState/") }
1002
+ power = status && status["code"].to_s.split("/", 2).last
1003
+
1004
+ {
1005
+ live: power ? POWER_STATES[power] : nil,
1006
+ state: power || "unknown",
1007
+ resource_id: virtual_machine_id(status_subscription_id(state), resource_group, vm_name),
1008
+ message: status && status["displayStatus"],
1009
+ }
1010
+ end
1011
+
1012
+ # An ARM client for a status probe, built from state the way {#destroy}
1013
+ # builds one, so that an instance created against another subscription
1014
+ # or cloud is still asked about in the right place.
1015
+ #
1016
+ # @param state [Hash] the instance state.
1017
+ # @return [Azure::ArmClient]
1018
+ def status_arm_client(state)
1019
+ @arm_client ||= Kitchen::Driver::AzureCredentials.new(
1020
+ subscription_id: status_subscription_id(state),
1021
+ environment: state[:azure_environment] || config[:azure_environment]
1022
+ ).arm_client
1023
+ end
1024
+
1025
+ # @param state [Hash] the instance state.
1026
+ # @return [String, nil] the subscription the instance was created in.
1027
+ def status_subscription_id(state)
1028
+ state[:subscription_id] || config[:subscription_id]
1029
+ end
1030
+
1031
+ # @param subscription_id [String]
1032
+ # @param resource_group [String]
1033
+ # @param vm_name [String]
1034
+ # @return [String] the virtual machine's ARM resource id.
1035
+ def virtual_machine_id(subscription_id, resource_group, vm_name)
1036
+ "/subscriptions/#{subscription_id}/resourceGroups/#{resource_group}" \
1037
+ "/providers/Microsoft.Compute/virtualMachines/#{vm_name}"
1038
+ end
1039
+
776
1040
  # Deletes an explicitly-named resource group when the instance itself was
777
1041
  # never created but the user asked for the group to be removed.
778
1042
  #
@@ -796,7 +1060,15 @@ module Kitchen
796
1060
  end
797
1061
 
798
1062
  # Empties a resource group by deploying an empty template in Complete
799
- # mode, then restores or clears the group's tags per configuration.
1063
+ # mode, then clears the group's tags unless asked to keep them.
1064
+ #
1065
+ # An empty Complete-mode deployment removes the resources in the group
1066
+ # and leaves the group's own tags alone, so keeping them means leaving
1067
+ # the group be. Rewriting it is what used to remove them: ARM's PUT on a
1068
+ # resource group replaces its tags rather than merging them, so putting
1069
+ # the *configured* tags back overwrote whatever the group actually
1070
+ # carried - which, with +resource_group_tags+ unset, meant erasing them
1071
+ # while announcing they would be kept.
800
1072
  #
801
1073
  # @param state [Hash] the instance state.
802
1074
  # @return [void]
@@ -807,11 +1079,11 @@ module Kitchen
807
1079
 
808
1080
  if config[:destroy_explicit_resource_group_tags] == false
809
1081
  warn 'The "destroy_explicit_resource_group_tags" setting value is set to "false". The tags on the resource group will NOT be removed.'
810
- create_resource_group(state[:azure_resource_group_name], get_resource_group)
811
- else
812
- warn 'The "destroy_explicit_resource_group_tags" setting value is set to "true". The tags on the resource group will be removed.'
813
- create_resource_group(state[:azure_resource_group_name], get_resource_group.merge(tags: {}))
1082
+ return
814
1083
  end
1084
+
1085
+ warn 'The "destroy_explicit_resource_group_tags" setting value is set to "true". The tags on the resource group will be removed.'
1086
+ create_resource_group(state[:azure_resource_group_name], get_resource_group.merge(tags: {}))
815
1087
  rescue Azure::OperationError => operation_error
816
1088
  error operation_error.body
817
1089
  raise operation_error
@@ -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.3".freeze
9
+ AZURERM_VERSION = "2.3.0".freeze
10
10
  end
11
11
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kitchen-azurerm
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.3
4
+ version: 2.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stuart Preston
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-23 00:00:00.000000000 Z
11
+ date: 2026-08-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: inifile
@@ -56,7 +56,7 @@ dependencies:
56
56
  requirements:
57
57
  - - ">="
58
58
  - !ruby/object:Gem::Version
59
- version: '1.20'
59
+ version: '3.0'
60
60
  - - "<"
61
61
  - !ruby/object:Gem::Version
62
62
  version: '5.0'
@@ -66,7 +66,7 @@ dependencies:
66
66
  requirements:
67
67
  - - ">="
68
68
  - !ruby/object:Gem::Version
69
- version: '1.20'
69
+ version: '3.0'
70
70
  - - "<"
71
71
  - !ruby/object:Gem::Version
72
72
  version: '5.0'