Module: IDRAC::Storage

Included in:
Client
Defined in:
lib/idrac/storage.rb

Instance Method Summary collapse

Instance Method Details

#all_seds?(drives) ⇒ Boolean

Check if all physical disks are Self-Encrypting Drives

Returns:

  • (Boolean)


516
517
518
# File 'lib/idrac/storage.rb', line 516

def all_seds?(drives)
  drives.all? { |d| d["encryption_ability"] == "SelfEncryptingDrive" }
end

#controller_encryption_capable?(controller) ⇒ Boolean

Check if the controller is capable of encryption

Returns:

  • (Boolean)


554
555
556
# File 'lib/idrac/storage.rb', line 554

def controller_encryption_capable?(controller)
  controller.dig("encryption_capability") =~ /localkey/i
end

#controller_encryption_enabled?(controller) ⇒ Boolean

Check if controller encryption is enabled

Returns:

  • (Boolean)


559
560
561
# File 'lib/idrac/storage.rb', line 559

def controller_encryption_enabled?(controller)
  controller.dig("encryption_mode") =~ /localkey/i
end

#controllersObject

Get all storage controllers and return them as an array



7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# File 'lib/idrac/storage.rb', line 7

def controllers
  response = authenticated_request(:get, '/redfish/v1/Systems/System.Embedded.1/Storage?$expand=*($levels=1)')
  
  if response.status == 200
    begin
      data = JSON.parse(response.body)
      
      # Transform and return all controllers as an array of hashes with string keys
      controllers = data["Members"].map do |controller|
        {
          "name" => controller["Name"],
          "model" => controller["Model"],
          "drives_count" => controller["Drives"].size,
          "status" => controller.dig("Status", "Health") || "N/A",
          "firmware_version" => controller.dig("StorageControllers", 0, "FirmwareVersion"),
          "encryption_mode" => controller.dig("Oem", "Dell", "DellController", "EncryptionMode"),
          "encryption_capability" => controller.dig("Oem", "Dell", "DellController", "EncryptionCapability"),
          "controller_type" => controller.dig("Oem", "Dell", "DellController", "ControllerType"),
          "pci_slot" => controller.dig("Oem", "Dell", "DellController", "PCISlot"),
          "raw" => controller,
          "@odata.id" => controller["@odata.id"]
        }
      end
      
      return controllers.sort_by { |c| c["name"] }
    rescue JSON::ParserError
      raise Error, "Failed to parse controllers response: #{response.body}"
    end
  else
    raise Error, "Failed to get controllers. Status code: #{response.status}"
  end
end

#create_virtual_disk(controller_id:, drives:, name: "vssd0", raid_type: "RAID5", encrypt: true) ⇒ Object

Create a new virtual disk with RAID5 and FastPath optimizations



299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
# File 'lib/idrac/storage.rb', line 299

def create_virtual_disk(controller_id:, drives:, name: "vssd0", raid_type: "RAID5", encrypt: true)
  raise "Drives must be an array of @odata.id strings" unless drives.all? { |d| d.is_a?(String) }
  
  # Get firmware version to determine approach
  firmware_version = get_firmware_version.split(".")[0,2].join.to_i
  
  # For older iDRAC firmware, use SCP method instead of API
  if firmware_version < 440
    return create_virtual_disk_scp(
      controller_id: controller_id,
      drives: drives,
      name: name,
      raid_type: raid_type,
      encrypt: encrypt
    )
  end
  
  # For newer firmware, use Redfish API
  drive_refs = drives.map { |d| { "@odata.id" => d.to_s } }
  
  # [FastPath optimization for SSDs](https://www.dell.com/support/manuals/en-us/perc-h755/perc11_ug/fastpath?guid=guid-a9e90946-a41f-48ab-88f1-9ce514b4c414&lang=en-us)
  payload = {
    "Links" => { "Drives" => drive_refs },
    "Name" => name,
    "OptimumIOSizeBytes" => 64 * 1024,
    "Oem" => { "Dell" => { "DellVolume" => { "DiskCachePolicy" => "Enabled" } } },
    "ReadCachePolicy" => "Off", # "NoReadAhead"
    "WriteCachePolicy" => "WriteThrough"
  }
  
  # For modern firmware
  if drives.size < 3 && raid_type == "RAID5"
    debug "Less than 3 drives. Selecting RAID0.", 1, :red
    payload["RAIDType"] = "RAID0"
  else
    payload["RAIDType"] = raid_type
  end
  
  payload["Encrypted"] = true if encrypt
  
  response = authenticated_request(
    :post, 
    "#{controller_id}/Volumes",
    body: payload.to_json, 
    headers: { 'Content-Type' => 'application/json' }
  )
  
  handle_response(response)
end

#create_virtual_disk_scp(controller_id:, drives:, name: "vssd0", raid_type: "RAID5", encrypt: true) ⇒ Object

System Configuration Profile - based VSSD0

This is required for older DELL iDRAC that
doesn't support the POST method with cache policies
nor encryption. 
When we remove 630/730's, we can remove this.

We want one volume – vssd0, RAID5, NO READ AHEAD, WRITE THROUGH, 64K STRIPE, ALL DISKS All we are doing here is manually setting WriteThrough. The rest is set correctly from the create_vssd0_post method. [FastPath](www.dell.com/support/manuals/en-us/poweredge-r7525/perc11_ug/fastpath?guid=guid-a9e90946-a41f-48ab-88f1-9ce514b4c414&lang=en-us) The PERC 11 series of cards support FastPath. To enable FastPath on a virtual disk, the cache policies of the RAID controller must be set to **write-through and no read ahead**. This enables FastPath to use the proper data path through the controller based on command (read/write), I/O size, and RAID type. For optimal solid-state drive performance, create virtual disks with **strip size of 64 KB**. Rest from: github.com/dell/iDRAC-Redfish-Scripting/blob/cc88a3db1bfb6cb5c6eea938ea6da67a84fb1dad/Redfish%20Python/CreateVirtualDiskREDFISH.py Create a RAID virtual disk using SCP for older iDRAC firmware



369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
# File 'lib/idrac/storage.rb', line 369

def create_virtual_disk_scp(controller_id:, drives:, name: "vssd0", raid_type: "RAID5", encrypt: true)
  # Extract the controller FQDD from controller_id
  controller_fqdd = controller_id.split("/").last
  
  # Get drive IDs in the required format
  drive_ids = drives.map do |drive_path|
    # Extract the disk FQDD from @odata.id
    drive_id = drive_path.split("/").last
    if drive_id.include?(":") # Already in FQDD format
      drive_id
    else
      # Need to convert to FQDD format
      "Disk.Bay.#{drive_id}:#{controller_fqdd}"
    end
  end
  
  debugger
  # Map RAID type to proper format
  raid_level = case raid_type
               when "RAID0" then "0"
               when "RAID1" then "1"
               when "RAID5" then "5"
               when "RAID6" then "6"
               when "RAID10" then "10"
               else raid_type.gsub("RAID", "")
               end
  
  # Create the virtual disk component
  vd_component = {
    "FQDD" => "Disk.Virtual.0:#{controller_fqdd}",
    "Attributes" => [
      { "Name" => "RAIDaction", "Value" => "Create", "Set On Import" => "True" },
      { "Name" => "Name", "Value" => name, "Set On Import" => "True" },
      { "Name" => "RAIDTypes", "Value" => "RAID #{raid_level}", "Set On Import" => "True" },
      { "Name" => "StripeSize", "Value" => "64KB", "Set On Import" => "True" }, # 64KB needed for FastPath
      { "Name" => "RAIDdefaultWritePolicy", "Value" => "WriteThrough", "Set On Import" => "True" },
      { "Name" => "RAIDdefaultReadPolicy", "Value" => "NoReadAhead", "Set On Import" => "True" },
      { "Name" => "DiskCachePolicy", "Value" => "Enabled", "Set On Import" => "True" }
    ]
  }
  
  # Add encryption if requested
  if encrypt
    vd_component["Attributes"] << { "Name" => "LockStatus", "Value" => "Unlocked", "Set On Import" => "True" }
  end
  
  # Add the include physical disks
  drive_ids.each do |disk_id|
    vd_component["Attributes"] << { 
      "Name" => "IncludedPhysicalDiskID", 
      "Value" => disk_id, 
      "Set On Import" => "True" 
    }
  end
  
  # Create an SCP with the controller component that contains the VD component
  controller_component = {
    "FQDD" => controller_fqdd,
    "Components" => [vd_component]
  }
  
  # Apply the SCP
  scp = { "SystemConfiguration" => { "Components" => [controller_component] } }
  result = set_system_configuration_profile(scp, target: "RAID", reboot: false)
  
  if result[:status] == :success
    return { status: :success, job_id: result[:job_id] }
  else
    raise Error, "Failed to create virtual disk: #{result[:error] || 'Unknown error'}"
  end
end

#delete_volume(odata_id) ⇒ Object

Delete a volume



289
290
291
292
293
294
295
296
# File 'lib/idrac/storage.rb', line 289

def delete_volume(odata_id)
  path = odata_id.split("v1/").last
  puts "Deleting volume: #{path}"
  
  response = authenticated_request(:delete, "/redfish/v1/#{path}")

  handle_response(response)
end

#disable_local_key_management(controller_id) ⇒ Object

Disable Self-Encrypting Drive support on controller



481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
# File 'lib/idrac/storage.rb', line 481

def disable_local_key_management(controller_id)
  payload = { "TargetFQDD": controller_id }
  
  response = authenticated_request(
    :post,
    "/redfish/v1/Dell/Systems/System.Embedded.1/DellRaidService/Actions/DellRaidService.RemoveControllerKey",
    body: payload.to_json,
    headers: { 'Content-Type': 'application/json' }
  )
  
  if response.status == 202
    puts "Controller encryption disabled".green
    
    # Check if we need to wait for a job
    if response.headers["location"]
      job_id = response.headers["location"].split("/").last
      wait_for_job(job_id)
    end
    
    return true
  else
    error_message = "Failed to disable controller encryption. Status code: #{response.status}"
    
    begin
      error_data = JSON.parse(response.body)
      error_message += ", Message: #{error_data['error']['message']}" if error_data['error'] && error_data['error']['message']
    rescue
      # Ignore JSON parsing errors
    end
    
    raise Error, error_message
  end
end

#drives(controller_id) ⇒ Object

Get information about physical drives

Raises:



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/idrac/storage.rb', line 93

def drives(controller_id) # expects @odata.id as string
  raise Error, "Controller ID not provided" unless controller_id
  raise Error, "Expected controller ID string, got #{controller_id.class}" unless controller_id.is_a?(String)
  
  controller_path = controller_id.split("v1/").last
  response = authenticated_request(:get, "/redfish/v1/#{controller_path}?$expand=*($levels=1)")
  
  if response.status == 200
    begin
      data = JSON.parse(response.body)
      
      # Debug dump of drive data - this happens with -vv or -vvv
      dump_drive_data(data["Drives"])
      
      drives = data["Drives"].map do |body|
        serial = body["SerialNumber"] 
        serial = body["Identifiers"].first["DurableName"] if serial.blank?
        {
          "serial" => serial,
          "model" => body["Model"],
          "name" => body["Name"],
          "capacity_bytes" => body["CapacityBytes"],
          "health" => body.dig("Status", "Health") || "N/A",
          "speed_gbp" => body["CapableSpeedGbs"],
          "manufacturer" => body["Manufacturer"],
          "media_type" => body["MediaType"],
          "failure_predicted" => body["FailurePredicted"],
          "life_left_percent" => body["PredictedMediaLifeLeftPercent"],
          "certified" => body.dig("Oem", "Dell", "DellPhysicalDisk", "Certified"),
          "raid_status" => body.dig("Oem", "Dell", "DellPhysicalDisk", "RaidStatus"),
          "operation_name" => body.dig("Oem", "Dell", "DellPhysicalDisk", "OperationName"),
          "operation_progress" => body.dig("Oem", "Dell", "DellPhysicalDisk", "OperationPercentCompletePercent"),
          "encryption_ability" => body["EncryptionAbility"],
          "@odata.id" => body["@odata.id"]
        }
      end
      
      return drives.sort_by { |d| d["name"] }
    rescue JSON::ParserError
      raise Error, "Failed to parse drives response: #{response.body}"
    end
  else
    raise Error, "Failed to get drives. Status code: #{response.status}"
  end
end

#dump_drive_data(drives) ⇒ Object

Helper method to display drive data in raw format



140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/idrac/storage.rb', line 140

def dump_drive_data(drives)
  
  self.debug "\n===== RAW DRIVE API DATA =====".green.bold
  drives.each_with_index do |drive, index|
    self.debug "\nDrive #{index + 1}: #{drive["Name"]}".cyan.bold
    self.debug "PredictedMediaLifeLeftPercent: #{drive["PredictedMediaLifeLeftPercent"].inspect}".yellow
    
    # Show other wear-related fields if they exist
    wear_fields = drive.keys.select { |k| k.to_s =~ /wear|life|health|predict/i }
    wear_fields.each do |field|
      self.debug "#{field}: #{drive[field].inspect}".yellow unless field == "PredictedMediaLifeLeftPercent"
    end
    
    # Show all data for full debug (verbosity level 3 / -vvv)
    self.debug "\nAll Drive Data:".light_magenta.bold
    self.debug JSON.pretty_generate(drive)
  end
  self.debug "\n===== END RAW DRIVE DATA =====\n".green.bold
end

#enable_local_key_management(controller_id:, passphrase: "Secure123!", key_id: "RAID-Key-2023") ⇒ Object

Enable Self-Encrypting Drive support on controller



442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
# File 'lib/idrac/storage.rb', line 442

def enable_local_key_management(controller_id:, passphrase: "Secure123!", key_id: "RAID-Key-2023")
  payload = { 
    "TargetFQDD": controller_id, 
    "Key": passphrase, 
    "Keyid": key_id 
  }
  
  response = authenticated_request(
    :post,
    "/redfish/v1/Dell/Systems/System.Embedded.1/DellRaidService/Actions/DellRaidService.SetControllerKey",
    body: payload.to_json,
    headers: { 'Content-Type': 'application/json' }
  )
  
  if response.status == 202
    puts "Controller encryption enabled".green
    
    # Check if we need to wait for a job
    if response.headers["location"]
      job_id = response.headers["location"].split("/").last
      wait_for_job(job_id)
    end
    
    return true
  else
    error_message = "Failed to enable controller encryption. Status code: #{response.status}"
    
    begin
      error_data = JSON.parse(response.body)
      error_message += ", Message: #{error_data['error']['message']}" if error_data['error'] && error_data['error']['message']
    rescue
      # Ignore JSON parsing errors
    end
    
    raise Error, error_message
  end
end

#fastpath_good?(volume) ⇒ Boolean

Check if FastPath is properly configured for a volume

Returns:

  • (Boolean)


268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
# File 'lib/idrac/storage.rb', line 268

def fastpath_good?(volume)
  return "disabled" unless volume

  # Note for older firmware, the stripe size is misreported as 128KB when it is actually 64KB (seen through the DELL Web UI), so ignore that:
  firmware_version = get_firmware_version.split(".")[0,2].join.to_i
  if firmware_version < 440
    stripe_size = "64KB"
  else
    stripe_size = volume["stripe_size"]
  end

  if volume["write_cache_policy"] == "WriteThrough" && 
     volume["read_cache_policy"] == "NoReadAhead" && 
     stripe_size == "64KB"
    return "enabled"
  else
    return "disabled"
  end
end

#find_controller(name_pattern: "PERC", prefer_most_drives_by_count: false, prefer_most_drives_by_size: false) ⇒ Hash

Find the best controller based on preference flags

Parameters:

  • name_pattern (String) (defaults to: "PERC")

    Regex pattern to match controller name (defaults to “PERC”)

  • prefer_most_drives_by_count (Boolean) (defaults to: false)

    Prefer controllers with more drives

  • prefer_most_drives_by_size (Boolean) (defaults to: false)

    Prefer controllers with larger total drive capacity

Returns:

  • (Hash)

    The selected controller



45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/idrac/storage.rb', line 45

def find_controller(name_pattern: "PERC", prefer_most_drives_by_count: false, prefer_most_drives_by_size: false)
  all_controllers = controllers
  return nil if all_controllers.empty?
  
  # Filter by name pattern if provided
  if name_pattern
    pattern_matches = all_controllers.select { |c| c["name"] && c["name"].include?(name_pattern) }
    return pattern_matches.first if pattern_matches.any?
  end
  
  selected_controller = nil
  
  # If we prefer controllers by drive count
  if prefer_most_drives_by_count
    selected_controller = all_controllers.max_by { |c| c["drives_count"] || 0 }
  end
  
  # If we prefer controllers by total drive size
  if prefer_most_drives_by_size && !selected_controller
    # We need to calculate total drive size for each controller
    controller_with_most_capacity = nil
    max_capacity = -1
    
    all_controllers.each do |controller|
      # Get the drives for this controller
      controller_drives = begin
        drives(controller["@odata.id"])
      rescue
        [] # If we can't get drives, assume empty
      end
      
      # Calculate total capacity
      total_capacity = controller_drives.sum { |d| d["capacity_bytes"] || 0 }
      
      if total_capacity > max_capacity
        max_capacity = total_capacity
        controller_with_most_capacity = controller
      end
    end
    
    selected_controller = controller_with_most_capacity if controller_with_most_capacity
  end
  
  # Default to first controller if no preferences matched
  selected_controller || all_controllers.first
end

#get_firmware_versionObject

Get firmware version



526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
# File 'lib/idrac/storage.rb', line 526

def get_firmware_version
  response = authenticated_request(:get, "/redfish/v1/Managers/iDRAC.Embedded.1?$select=FirmwareVersion")
  
  if response.status == 200
    begin
      data = JSON.parse(response.body)
      return data["FirmwareVersion"]
    rescue JSON::ParserError
      raise Error, "Failed to parse firmware version response: #{response.body}"
    end
  else
    # Try again without the $select parameter for older firmware
    response = authenticated_request(:get, "/redfish/v1/Managers/iDRAC.Embedded.1")
    
    if response.status == 200
      begin
        data = JSON.parse(response.body)
        return data["FirmwareVersion"]
      rescue JSON::ParserError
        raise Error, "Failed to parse firmware version response: #{response.body}"
      end
    else
      raise Error, "Failed to get firmware version. Status code: #{response.status}"
    end
  end
end

#sed_ready?(controller, drives) ⇒ Boolean

Check if the system is ready for SED operations

Returns:

  • (Boolean)


521
522
523
# File 'lib/idrac/storage.rb', line 521

def sed_ready?(controller, drives)
  all_seds?(drives) && controller_encryption_capable?(controller) && controller_encryption_enabled?(controller)
end

#volumes(controller_id) ⇒ Object

Get information about virtual disk volumes

Raises:



161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
# File 'lib/idrac/storage.rb', line 161

def volumes(controller_id) # expects @odata.id as string
  raise Error, "Controller ID not provided" unless controller_id
  raise Error, "Expected controller ID string, got #{controller_id.class}" unless controller_id.is_a?(String)
  
  puts "Volumes (e.g. Arrays)".green
  
  odata_id_path = controller_id + "/Volumes"
  response = authenticated_request(:get, "#{odata_id_path}?$expand=*($levels=1)")
  
  if response.status == 200
    begin
      data = JSON.parse(response.body)
      
      # Check if we need SCP data (older firmware)
      scp_data = nil
      controller_fqdd = controller_id.split("/").last
      
      # Get SCP data if needed (older firmware won't have these OEM attributes)
      if data["Members"].any? && 
         data["Members"].first&.dig("Oem", "Dell", "DellVirtualDisk", "WriteCachePolicy").nil?
        scp_data = get_system_configuration_profile(target: "RAID")
      end
      
      volumes = data["Members"].map do |vol|
        drives = vol["Links"]["Drives"]
        volume_data = { 
          "name" => vol["Name"], 
          "capacity_bytes" => vol["CapacityBytes"], 
          "volume_type" => vol["VolumeType"],
          "drives" => drives,
          "raid_level" => vol["RAIDType"],
          "encrypted" => vol["Encrypted"],
          "@odata.id" => vol["@odata.id"]
        }
        
        # Try to get cache policies from OEM data first (newer firmware)
        volume_data["write_cache_policy"] = vol.dig("Oem", "Dell", "DellVirtualDisk", "WriteCachePolicy")
        volume_data["read_cache_policy"] = vol.dig("Oem", "Dell", "DellVirtualDisk", "ReadCachePolicy")
        volume_data["stripe_size"] = vol.dig("Oem", "Dell", "DellVirtualDisk", "StripeSize")
        volume_data["lock_status"] = vol.dig("Oem", "Dell", "DellVirtualDisk", "LockStatus")
        
        # If we have SCP data and missing some policies, look them up from SCP
        if scp_data && (volume_data["write_cache_policy"].nil? || 
                        volume_data["read_cache_policy"].nil? || 
                        volume_data["stripe_size"].nil?)
          
          # Find controller component in SCP
          controller_comp = scp_data.dig("SystemConfiguration", "Components")&.find do |comp|
            comp["FQDD"] == controller_fqdd
          end
          
          if controller_comp
            # Try to find the matching virtual disk
            # Format is typically "Disk.Virtual.X:RAID...."
            vd_name = vol["Id"] || vol["Name"]
            vd_comp = controller_comp["Components"]&.find do |comp|
              comp["FQDD"] =~ /Disk\.Virtual\.\d+:#{controller_fqdd}/
            end
            
            if vd_comp && vd_comp["Attributes"]
              # Extract values from SCP
              write_policy = vd_comp["Attributes"].find { |a| a["Name"] == "RAIDdefaultWritePolicy" }
              read_policy = vd_comp["Attributes"].find { |a| a["Name"] == "RAIDdefaultReadPolicy" }
              stripe = vd_comp["Attributes"].find { |a| a["Name"] == "StripeSize" }
              lock_status = vd_comp["Attributes"].find { |a| a["Name"] == "LockStatus" }
              raid_level = vd_comp["Attributes"].find { |a| a["Name"] == "RAIDTypes" }
              
              volume_data["write_cache_policy"] ||= write_policy&.dig("Value")
              volume_data["read_cache_policy"] ||= read_policy&.dig("Value")
              volume_data["stripe_size"] ||= stripe&.dig("Value")
              volume_data["lock_status"] ||= lock_status&.dig("Value")
              volume_data["raid_level"] ||= raid_level&.dig("Value")
            end
          end
        end
        
        # Check FastPath settings
        volume_data["fastpath"] = fastpath_good?(volume_data)
        
        # Handle volume operations and status
        if vol["Operations"].any?
          volume_data["health"] = vol.dig("Status", "Health") || "N/A"
          volume_data["progress"] = vol["Operations"].first["PercentageComplete"]
          volume_data["message"] = vol["Operations"].first["OperationName"]     
        elsif vol.dig("Status", "Health") == "OK"
          volume_data["health"] = "OK"
          volume_data["progress"] = nil
          volume_data["message"] = nil
        else
          volume_data["health"] = "?"
          volume_data["progress"] = nil
          volume_data["message"] = nil
        end
        
        volume_data
      end
      
      return volumes.sort_by { |d| d["name"] }
    rescue JSON::ParserError
      raise Error, "Failed to parse volumes response: #{response.body}"
    end
  else
    raise Error, "Failed to get volumes. Status code: #{response.status}"
  end
end