idrac 0.8.6 → 0.8.7

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: ac429e71f14867043a891ec594d159e49210c4662f6c8993273423fc47470aea
4
- data.tar.gz: e12a69a35a3846944b7868a301c9d59210afd855f8fd6a7ab59bb4a2f7f07140
3
+ metadata.gz: 23b4d692e6a2623cda4369dafdc0e88755cf8e1eef198c83f85df88d670731ca
4
+ data.tar.gz: 9604b4d3dbc0277f9ffd7e1cf1029ece1517cb6a3868bc03dbc79d6abaccd74e
5
5
  SHA512:
6
- metadata.gz: 26d106fec37924d587fbcc774e3d81a0a9a464b10fd68b831df60e08fcbcbfa8bec012e216cfa5ef13732f9198d5a2d64f6ac6c01393d6b088cd6d4d3ebc6eb8
7
- data.tar.gz: 1144098c052f5d7939ff0fbcda31273e5287718a27803fd597f5decc4a90a9a0aa909ad9857f70b77063bfece56716734acf970afa8cefca7c20adb73ead2558
6
+ metadata.gz: 3646343bd2da2105448b15160f96c358e2971996daff9b1659ec4686f7f4cb259d76bf741205804c081e8b58e97b4060b0f2470bb9729bca5e480cdb37ad430e
7
+ data.tar.gz: d784e26447e27b12b87e873f9780dae75fd85ac895927b786b84c51e8b332d6215a357636b7e66b1524abfbef941192d8ef3d27a09b1fb3937113d21c7e22e23
data/lib/idrac/boot.rb CHANGED
@@ -555,9 +555,12 @@ module IDRAC
555
555
  begin
556
556
  data = JSON.parse(response.body)
557
557
  if data["Attributes"] && data["Attributes"].has_key?("ErrPrompt")
558
- return data["Attributes"]["ErrPrompt"] == "Disabled"
558
+ current_value = data["Attributes"]["ErrPrompt"]
559
+ debug "ErrPrompt current value: '#{current_value}' (checking if == 'Disabled')", 1, :cyan
560
+ return current_value == "Disabled"
559
561
  else
560
562
  debug "ErrPrompt attribute not found in BIOS settings", 1, :yellow
563
+ debug "Available BIOS attributes: #{data['Attributes']&.keys&.sort&.join(', ')}", 2, :yellow if data["Attributes"]
561
564
  return false
562
565
  end
563
566
  rescue JSON::ParserError
@@ -640,18 +643,18 @@ module IDRAC
640
643
 
641
644
  settings.each do |key, value|
642
645
  attributes << {
643
- "Name": key.to_s,
644
- "Value": value,
645
- "Set On Import": "True"
646
+ "Name" => key.to_s,
647
+ "Value" => value,
648
+ "Set On Import" => "True"
646
649
  }
647
650
  end
648
651
 
649
652
  scp = {
650
- "SystemConfiguration": {
651
- "Components": [
653
+ "SystemConfiguration" => {
654
+ "Components" => [
652
655
  {
653
- "FQDD": "BIOS.Setup.1-1",
654
- "Attributes": attributes
656
+ "FQDD" => "BIOS.Setup.1-1",
657
+ "Attributes" => attributes
655
658
  }
656
659
  ]
657
660
  }
@@ -663,9 +666,9 @@ module IDRAC
663
666
  # Import System Configuration Profile for advanced configurations
664
667
  def import_system_configuration(scp, target: "ALL", reboot: false)
665
668
  params = {
666
- "ImportBuffer": JSON.pretty_generate(scp),
667
- "ShareParameters": {
668
- "Target": target
669
+ "ImportBuffer" => JSON.generate(scp),
670
+ "ShareParameters" => {
671
+ "Target" => target
669
672
  }
670
673
  }
671
674
  # Configure shutdown behavior
data/lib/idrac/client.rb CHANGED
@@ -193,6 +193,12 @@ module IDRAC
193
193
  original_timeout = conn.options.timeout
194
194
  original_open_timeout = conn.options.open_timeout
195
195
 
196
+ # Add host header if specified (needed for SSH tunnels to iDRAC)
197
+ if @host_header
198
+ headers = headers.merge('Host' => @host_header)
199
+ debug "Added Host header to iDRAC request: #{@host_header}", 2
200
+ end
201
+
196
202
  begin
197
203
  conn.options.timeout = timeout if timeout
198
204
  conn.options.open_timeout = open_timeout if open_timeout
@@ -360,6 +366,17 @@ module IDRAC
360
366
  retries = 0
361
367
  begin
362
368
  yield
369
+ rescue ServiceTemporarilyUnavailableError => e
370
+ retries += 1
371
+ if retries <= max_retries
372
+ delay = e.retry_delay # Use the delay specified by iDRAC
373
+ debug "🕒 IDRAC REQUESTED RETRY: ServiceTemporarilyUnavailable - Attempt #{retries}/#{max_retries}, waiting #{delay}s as instructed by iDRAC", 1, :cyan
374
+ sleep delay
375
+ retry
376
+ else
377
+ debug "MAX RETRIES REACHED: #{e.message} after #{max_retries} attempts", 1, :red
378
+ raise e
379
+ end
363
380
  rescue *error_classes => e
364
381
  retries += 1
365
382
  if retries <= max_retries
@@ -446,7 +463,54 @@ module IDRAC
446
463
  if response.status.between?(200, 299)
447
464
  return response.body
448
465
  else
449
- raise Error, "Failed to #{response.status} - #{response.body}"
466
+ # Enhanced error handling with ExtendedInfo support
467
+ error_message = "Failed with status #{response.status}"
468
+
469
+ begin
470
+ error_data = JSON.parse(response.body)
471
+
472
+ # Check for standard error message
473
+ if error_data['error'] && error_data['error']['message']
474
+ error_message += ": #{error_data['error']['message']}"
475
+ end
476
+
477
+ # Check for ExtendedInfo which contains detailed error information
478
+ if error_data['error'] && error_data['error']['@Message.ExtendedInfo']
479
+ extended_info = error_data['error']['@Message.ExtendedInfo']
480
+ if extended_info.is_a?(Array) && extended_info.any?
481
+ error_message += "\nExtendedInfo:"
482
+ retry_delay = nil
483
+ extended_info.each_with_index do |info, index|
484
+ error_message += "\n #{index + 1}. #{info['Message']}" if info['Message']
485
+ error_message += " (#{info['MessageId']})" if info['MessageId']
486
+ error_message += " - Resolution: #{info['Resolution']}" if info['Resolution']
487
+
488
+ # Check for ServiceTemporarilyUnavailable with retry delay
489
+ if info['MessageId'] == 'Base.1.12.ServiceTemporarilyUnavailable'
490
+ # Extract retry delay from MessageArgs (usually first argument)
491
+ if info['MessageArgs'] && info['MessageArgs'].any?
492
+ retry_delay = info['MessageArgs'].first.to_i
493
+ debug "🕒 iDRAC ServiceTemporarilyUnavailable detected - will wait #{retry_delay} seconds as requested", 1, :yellow
494
+ end
495
+ end
496
+ end
497
+
498
+ # If we detected a ServiceTemporarilyUnavailable error, raise a special exception
499
+ if retry_delay
500
+ raise ServiceTemporarilyUnavailableError.new(error_message, retry_delay)
501
+ end
502
+ end
503
+ end
504
+
505
+ # Also add the full response body for debugging
506
+ debug "Full error response: #{response.body}", 1, :red if @verbosity && @verbosity > 0
507
+
508
+ rescue JSON::ParserError => e
509
+ error_message += " - Raw response: #{response.body}"
510
+ debug "Failed to parse JSON error response: #{e.message}", 1, :yellow if @verbosity && @verbosity > 0
511
+ end
512
+
513
+ raise Error, error_message
450
514
  end
451
515
  end
452
516
 
data/lib/idrac/error.rb CHANGED
@@ -1,3 +1,12 @@
1
1
  module IDRAC
2
2
  class Error < StandardError; end
3
+
4
+ class ServiceTemporarilyUnavailableError < Error
5
+ attr_reader :retry_delay
6
+
7
+ def initialize(message, retry_delay)
8
+ super(message)
9
+ @retry_delay = retry_delay
10
+ end
11
+ end
3
12
  end
data/lib/idrac/license.rb CHANGED
@@ -41,6 +41,18 @@ module IDRAC
41
41
  # Extracts the iDRAC version from the license description or server header
42
42
  # @return [Integer, nil] The license version (e.g. 9) or nil if not found
43
43
  def license_version
44
+ # Use memoization to cache the result and avoid multiple API calls
45
+ @license_version ||= compute_license_version
46
+ end
47
+
48
+ # Clear the cached license version (useful if iDRAC state changes)
49
+ def clear_license_version_cache
50
+ @license_version = nil
51
+ end
52
+
53
+ private
54
+
55
+ def compute_license_version
44
56
  # First try to get from license info
45
57
  license = license_info
46
58
  if license
@@ -80,8 +92,6 @@ module IDRAC
80
92
  nil
81
93
  end
82
94
 
83
- private
84
-
85
95
  # Attempt to get license information using Dell OEM extension path (for iDRAC 8)
86
96
  # @return [Hash, nil] License info or nil if not found
87
97
  def try_dell_oem_license_path
data/lib/idrac/network.rb CHANGED
@@ -4,6 +4,40 @@ require 'json'
4
4
 
5
5
  module IDRAC
6
6
  module Network
7
+ # Get iDRAC version information following Dell's approach
8
+ def get_idrac_version_info
9
+ # Get iDRAC model to determine generation (following Dell's pattern)
10
+ manager_response = authenticated_request(:get, "/redfish/v1/Managers/iDRAC.Embedded.1?$select=Model,FirmwareVersion")
11
+
12
+ if manager_response.status == 200
13
+ manager_data = JSON.parse(manager_response.body)
14
+ model = manager_data["Model"]
15
+ firmware_version = manager_data["FirmwareVersion"]
16
+
17
+ # Determine iDRAC generation based on model (Dell's approach)
18
+ if model.include?("12") || model.include?("13")
19
+ idrac_generation = 8
20
+ elsif model.include?("14") || model.include?("15") || model.include?("16")
21
+ idrac_generation = 9
22
+ else
23
+ idrac_generation = 10 # iDRAC9 and newer
24
+ end
25
+
26
+ # Convert firmware version to numeric for comparison (Dell's approach)
27
+ firmware_numeric = firmware_version.gsub(".", "").to_i if firmware_version
28
+
29
+ {
30
+ generation: idrac_generation,
31
+ firmware_version: firmware_version,
32
+ firmware_numeric: firmware_numeric,
33
+ model: model
34
+ }
35
+ else
36
+ # Fallback - assume newer version if we can't determine
37
+ { generation: 9, firmware_version: "unknown", firmware_numeric: 0, model: "unknown" }
38
+ end
39
+ end
40
+
7
41
  def get_bmc_network
8
42
  # Get the iDRAC ethernet interface
9
43
  collection_response = authenticated_request(:get, "/redfish/v1/Managers/iDRAC.Embedded.1/EthernetInterfaces")
@@ -45,39 +79,72 @@ module IDRAC
45
79
  def set_bmc_network(ipv4: nil, mask: nil, gateway: nil,
46
80
  dns_primary: nil, dns_secondary: nil, hostname: nil,
47
81
  dhcp: false)
48
- # Get the interface path first
82
+ puts "🔧 iDRAC set_bmc_network called with: ipv4=#{ipv4}, mask=#{mask}, gateway=#{gateway}, dhcp=#{dhcp}".cyan
83
+
84
+ # Get iDRAC version information first (following Dell's approach)
85
+ begin
86
+ puts "🔍 Getting iDRAC version information...".yellow
87
+ version_info = get_idrac_version_info
88
+ puts "✅ Detected iDRAC Generation #{version_info[:generation]}, Firmware: #{version_info[:firmware_version]}".green
89
+ puts " Model: #{version_info[:model]}, Firmware Numeric: #{version_info[:firmware_numeric]}".cyan
90
+ rescue => e
91
+ puts "❌ Error getting iDRAC version info: #{e.class} - #{e.message}".red
92
+ raise e
93
+ end
94
+
95
+ # Get the interface path
96
+ puts "🔍 Getting ethernet interfaces collection...".yellow
49
97
  collection_response = authenticated_request(:get, "/redfish/v1/Managers/iDRAC.Embedded.1/EthernetInterfaces")
50
98
 
51
99
  if collection_response.status == 200
100
+ puts "✅ Got ethernet interfaces collection successfully".green
52
101
  collection = JSON.parse(collection_response.body)
102
+ puts "🔍 Collection members: #{collection["Members"]&.size || 0} interfaces found".cyan
53
103
 
54
104
  if collection["Members"] && collection["Members"].any?
55
105
  interface_path = collection["Members"][0]["@odata.id"]
106
+ puts "✅ Using interface path: #{interface_path}".green
56
107
 
57
108
  if dhcp
58
109
  puts "Setting iDRAC to DHCP mode...".yellow
59
110
  body = {
60
111
  "DHCPv4" => {
61
112
  "DHCPEnabled" => true
62
- },
63
- "IPv4Addresses" => [{
64
- "AddressOrigin" => "DHCP"
65
- }]
113
+ }
66
114
  }
67
115
  else
68
116
  puts "Configuring iDRAC network settings...".yellow
69
117
  body = {}
70
118
 
71
- # Configure static IP if provided
72
- if ipv4 && mask
73
- body["IPv4Addresses"] = [{
74
- "Address" => ipv4,
75
- "SubnetMask" => mask,
76
- "Gateway" => gateway,
77
- "AddressOrigin" => "Static"
78
- }]
79
- puts " IP: #{ipv4}/#{mask}".cyan
80
- puts " Gateway: #{gateway}".cyan if gateway
119
+ # Choose API approach based on iDRAC generation and firmware version
120
+ if version_info[:generation] >= 9 && version_info[:firmware_numeric] > 0
121
+ # iDRAC9/10 - use System Configuration Profile (SCP) approach for reliable network changes
122
+ puts "Using iDRAC9+ SCP approach for network configuration".yellow
123
+ puts " Delegating to set_idrac_ip method for reliable configuration".cyan
124
+
125
+ # Use the existing set_idrac_ip method which uses SCP
126
+ return set_idrac_ip(new_ip: ipv4, new_gw: gateway, new_nm: mask)
127
+
128
+ else
129
+ # iDRAC8 or older firmware - use IPv4Addresses approach
130
+ puts "Using legacy iDRAC8 API approach (IPv4Addresses)".yellow
131
+
132
+ # Disable DHCP first for older versions
133
+ body["DHCPv4"] = {
134
+ "DHCPEnabled" => false
135
+ }
136
+
137
+ # Configure static IP using legacy API
138
+ if ipv4 && mask
139
+ body["IPv4Addresses"] = [{
140
+ "Address" => ipv4,
141
+ "SubnetMask" => mask,
142
+ "Gateway" => gateway,
143
+ "AddressOrigin" => "Static"
144
+ }]
145
+ puts " IP: #{ipv4}/#{mask}".cyan
146
+ puts " Gateway: #{gateway}".cyan if gateway
147
+ end
81
148
  end
82
149
 
83
150
  # Configure DNS if provided
@@ -96,24 +163,64 @@ module IDRAC
96
163
  end
97
164
  end
98
165
 
99
- response = authenticated_request(
100
- :patch,
101
- interface_path,
102
- body: body.to_json,
103
- headers: { 'Content-Type' => 'application/json' }
104
- )
166
+ # Send the request using the version-specific approach
167
+ puts "🔍 Sending PATCH request to #{interface_path}".yellow
168
+ puts "📦 Request body: #{JSON.pretty_generate(body)}".cyan
169
+
170
+ begin
171
+ response = authenticated_request(
172
+ :patch,
173
+ interface_path,
174
+ body: body.to_json,
175
+ headers: { 'Content-Type' => 'application/json' }
176
+ )
177
+ puts "✅ Got response with status: #{response.status}".green
178
+ rescue => e
179
+ puts "❌ Error sending PATCH request: #{e.class} - #{e.message}".red
180
+ raise e
181
+ end
105
182
 
106
183
  if response.status.between?(200, 299)
107
- puts "iDRAC network configured successfully.".green
108
- puts "WARNING: iDRAC may restart network services. Connection may be lost.".yellow if ip_address
184
+ puts "iDRAC network configured successfully using #{version_info[:generation] >= 9 ? 'newer' : 'legacy'} API.".green
185
+
186
+ # For network configuration changes, automatically restart iDRAC to apply settings
187
+ if ipv4 && !dhcp
188
+ puts "Initiating iDRAC restart to apply network configuration...".yellow
189
+ restart_response = authenticated_request(
190
+ :post,
191
+ "/redfish/v1/Managers/iDRAC.Embedded.1/Actions/Manager.Reset",
192
+ body: { "ResetType" => "GracefulRestart" }.to_json,
193
+ headers: { 'Content-Type' => 'application/json' }
194
+ )
195
+
196
+ if restart_response.status == 204
197
+ puts "✓ iDRAC restart initiated successfully.".green
198
+ puts "Network configuration will be applied after restart (2-3 minutes).".cyan
199
+ puts "New IP: #{ipv4}".cyan
200
+ else
201
+ puts "⚠ iDRAC restart failed (#{restart_response.status}). You may need to restart manually.".yellow
202
+ puts "Configuration is saved but may not be active until restart.".yellow
203
+ end
204
+ else
205
+ puts "WARNING: iDRAC may restart network services. Connection may be lost.".yellow
206
+ end
207
+
208
+ puts "✅ Returning true from set_bmc_network".green
109
209
  true
110
210
  else
111
- raise Error, "Failed to configure iDRAC network: #{response.status} - #{response.body}"
211
+ # Log the error with version context for troubleshooting
212
+ puts "❌ Network configuration failed with status: #{response.status}".red
213
+ puts "🔍 Response body: #{response.body}".red
214
+ error_msg = "Failed to configure iDRAC network (Generation #{version_info[:generation]}, FW #{version_info[:firmware_version]}): #{response.status} - #{response.body}"
215
+ raise Error, error_msg
112
216
  end
113
217
  else
218
+ puts "❌ No ethernet interfaces found in collection".red
114
219
  raise Error, "No ethernet interfaces found"
115
220
  end
116
221
  else
222
+ puts "❌ Failed to get ethernet interfaces collection, status: #{collection_response.status}".red
223
+ puts "🔍 Response body: #{collection_response.body}".red
117
224
  raise Error, "Failed to get ethernet interfaces"
118
225
  end
119
226
  end
data/lib/idrac/session.rb CHANGED
@@ -184,6 +184,10 @@ module IDRAC
184
184
  # Determine the correct session endpoint based on Redfish version
185
185
  session_endpoint = determine_session_endpoint
186
186
 
187
+ # Check current session count before creating new session
188
+ current_sessions = get_session_count
189
+ debug "Current active sessions: #{current_sessions}", 1, :cyan
190
+
187
191
  payload = { "UserName" => username, "Password" => password }
188
192
 
189
193
  debug "Attempting to create Redfish session at #{base_url}#{session_endpoint}", 1
@@ -191,10 +195,22 @@ module IDRAC
191
195
  print_connection_debug_info if @verbosity >= 2
192
196
 
193
197
  # Try creation methods in sequence
194
- return true if create_session_with_content_type(session_endpoint, payload)
195
- return true if create_session_with_basic_auth(session_endpoint, payload)
196
- return true if handle_max_sessions_and_retry(session_endpoint, payload)
197
- return true if create_session_with_form_urlencoded(session_endpoint, payload)
198
+ if create_session_with_content_type(session_endpoint, payload)
199
+ log_session_creation_success(current_sessions)
200
+ return true
201
+ end
202
+ if create_session_with_basic_auth(session_endpoint, payload)
203
+ log_session_creation_success(current_sessions)
204
+ return true
205
+ end
206
+ if handle_max_sessions_and_retry(session_endpoint, payload)
207
+ log_session_creation_success(current_sessions)
208
+ return true
209
+ end
210
+ if create_session_with_form_urlencoded(session_endpoint, payload)
211
+ log_session_creation_success(current_sessions)
212
+ return true
213
+ end
198
214
 
199
215
  # If all attempts fail, switch to direct mode
200
216
  @direct_mode = true
@@ -678,5 +694,34 @@ module IDRAC
678
694
  debug "Defaulting to endpoint #{default_endpoint}", 1, :light_yellow
679
695
  default_endpoint
680
696
  end
697
+
698
+ # Get current session count
699
+ def get_session_count
700
+ begin
701
+ sessions_url = determine_session_endpoint
702
+ response = request_with_basic_auth(:get, sessions_url, nil, 'application/json')
703
+
704
+ if response.status == 200
705
+ sessions_data = JSON.parse(response.body)
706
+ count = sessions_data.dig('Members')&.size || 0
707
+ return count
708
+ end
709
+ rescue => e
710
+ debug "Error getting session count: #{e.message}", 2, :yellow
711
+ end
712
+
713
+ # Return unknown if we can't determine
714
+ "unknown"
715
+ end
716
+
717
+ # Log successful session creation with before/after count
718
+ def log_session_creation_success(before_count)
719
+ after_count = get_session_count
720
+ if before_count.is_a?(Integer) && after_count.is_a?(Integer)
721
+ debug "✅ Session created successfully! Count: #{before_count} → #{after_count} (+#{after_count - before_count})", 1, :green
722
+ else
723
+ debug "✅ Session created successfully! Previous: #{before_count}, Current: #{after_count}", 1, :green
724
+ end
725
+ end
681
726
  end
682
727
  end
data/lib/idrac/storage.rb CHANGED
@@ -384,7 +384,23 @@ module IDRAC
384
384
  end
385
385
 
386
386
  # For newer firmware, use Redfish API
387
- drive_refs = drives.map { |d| { "@odata.id" => d.to_s } }
387
+ drive_refs = drives.map do |d|
388
+ # Convert drive identifier to proper @odata.id format
389
+ if d.to_s.start_with?('/redfish/v1/')
390
+ # Already in @odata.id format
391
+ { "@odata.id" => d.to_s }
392
+ else
393
+ # Convert FQDD to @odata.id format
394
+ drive_fqdd = d.to_s
395
+ # Build the full controller path for the drive reference
396
+ full_controller_path = if controller_id.start_with?('/redfish/v1/')
397
+ controller_id
398
+ else
399
+ "/redfish/v1/Systems/System.Embedded.1/Storage/#{controller_id}"
400
+ end
401
+ { "@odata.id" => "#{full_controller_path}/Drives/#{drive_fqdd}" }
402
+ end
403
+ end
388
404
 
389
405
  # [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)
390
406
  payload = {
@@ -406,9 +422,16 @@ module IDRAC
406
422
 
407
423
  payload["Encrypted"] = true if encrypt
408
424
 
425
+ # Ensure we have the full path to the controller
426
+ controller_path = if controller_id.start_with?('/redfish/v1/')
427
+ controller_id
428
+ else
429
+ "/redfish/v1/Systems/System.Embedded.1/Storage/#{controller_id}"
430
+ end
431
+
409
432
  response = authenticated_request(
410
433
  :post,
411
- "#{controller_id}/Volumes",
434
+ "#{controller_path}/Volumes",
412
435
  body: payload.to_json,
413
436
  headers: { 'Content-Type' => 'application/json' }
414
437
  )
@@ -533,21 +556,13 @@ module IDRAC
533
556
 
534
557
  return true
535
558
  else
536
- error_message = "Failed to enable controller encryption. Status code: #{response.status}"
537
-
538
- begin
539
- error_data = JSON.parse(response.body)
540
- error_message += ", Message: #{error_data['error']['message']}" if error_data['error'] && error_data['error']['message']
541
- rescue
542
- # Ignore JSON parsing errors
543
- end
544
-
545
- raise Error, error_message
559
+ # Use generic error handler which includes ExtendedInfo parsing
560
+ handle_response(response)
546
561
  end
547
562
  end
548
563
 
549
564
  # Disable Self-Encrypting Drive support on controller
550
- def disable_local_key_management(controller_id)
565
+ def disable_local_key_management(controller_id:)
551
566
  payload = { "TargetFQDD": controller_id }
552
567
 
553
568
  response = authenticated_request(
@@ -568,16 +583,8 @@ module IDRAC
568
583
 
569
584
  return true
570
585
  else
571
- error_message = "Failed to disable controller encryption. Status code: #{response.status}"
572
-
573
- begin
574
- error_data = JSON.parse(response.body)
575
- error_message += ", Message: #{error_data['error']['message']}" if error_data['error'] && error_data['error']['message']
576
- rescue
577
- # Ignore JSON parsing errors
578
- end
579
-
580
- raise Error, error_message
586
+ # Use generic error handler which includes ExtendedInfo parsing
587
+ handle_response(response)
581
588
  end
582
589
  end
583
590
 
@@ -96,17 +96,24 @@ module IDRAC
96
96
  components = scp.is_a?(Array) ? scp : [scp]
97
97
  { "SystemConfiguration" => { "Components" => components } }
98
98
  end
99
+
100
+ # Validate the SCP structure before sending
101
+ unless scp_to_apply.is_a?(Hash) && scp_to_apply["SystemConfiguration"] && scp_to_apply["SystemConfiguration"]["Components"]
102
+ raise ArgumentError, "Invalid SCP structure: must contain SystemConfiguration.Components"
103
+ end
99
104
 
100
105
  # Create the import parameters
106
+ # Use compact JSON generation to avoid formatting issues with Dell iDRAC
101
107
  params = {
102
- "ImportBuffer" => JSON.pretty_generate(scp_to_apply),
108
+ "ImportBuffer" => JSON.generate(scp_to_apply),
103
109
  "ShareParameters" => {"Target" => target},
104
110
  "ShutdownType" => "Forced",
105
111
  "HostPowerState" => reboot ? "On" : "Off"
106
112
  }
107
113
 
108
114
  debug "Importing System Configuration...", 1, :blue
109
- debug "Configuration: #{JSON.pretty_generate(scp_to_apply)}", 3
115
+ debug "Configuration: #{JSON.pretty_generate(scp_to_apply)}", 1, :cyan
116
+ debug "ImportBuffer content: #{params['ImportBuffer']}", 1, :yellow
110
117
 
111
118
  # Make the API request
112
119
  response = authenticated_request(
@@ -119,7 +126,16 @@ module IDRAC
119
126
  # Check for immediate errors
120
127
  if response.headers["content-length"].to_i > 0
121
128
  debug response.inspect, 1, :red
122
- return { status: :failed, error: "Failed importing SCP: #{response.body}" }
129
+ error_message = "Failed importing SCP: #{response.body}"
130
+
131
+ # Check for specific schema validation errors
132
+ if response.body.include?("invalid characters") || response.body.include?("invalid token")
133
+ error_message += "\nThis may be due to JSON formatting issues. The SCP structure might contain characters not accepted by Dell iDRAC."
134
+ elsif response.body.include?("not compliant with configuration schema")
135
+ error_message += "\nThe SCP structure does not match Dell's expected schema format."
136
+ end
137
+
138
+ return { status: :failed, error: error_message }
123
139
  end
124
140
 
125
141
  return handle_location(response.headers["location"])
@@ -188,7 +204,23 @@ module IDRAC
188
204
  # Convert an SCP hash back to array format
189
205
  def hash_to_scp(hash)
190
206
  hash.inject([]) do |acc, (fqdd, attributes)|
191
- acc << { "FQDD" => fqdd, "Attributes" => attributes }
207
+ # Convert hash attributes to Dell SCP format (array of Name/Value/Set On Import objects)
208
+ scp_attributes = case attributes
209
+ when Hash
210
+ attributes.map do |name, value|
211
+ {
212
+ "Name" => name.to_s,
213
+ "Value" => value.to_s,
214
+ "Set On Import" => "True"
215
+ }
216
+ end
217
+ when Array
218
+ attributes # Already in correct format
219
+ else
220
+ []
221
+ end
222
+
223
+ acc << { "FQDD" => fqdd, "Attributes" => scp_attributes }
192
224
  acc
193
225
  end
194
226
  end
@@ -198,6 +230,13 @@ module IDRAC
198
230
  def merge_scp(*scps)
199
231
  merged_components = {}
200
232
 
233
+ # Get iDRAC version for version-specific handling
234
+ version = begin
235
+ license_version.to_i
236
+ rescue
237
+ 9 # Default to iDRAC 9 behavior if version detection fails
238
+ end
239
+
201
240
  scps.compact.each do |scp|
202
241
  components = extract_components(scp)
203
242
  components.each do |component|
@@ -209,10 +248,34 @@ module IDRAC
209
248
 
210
249
  # Build hash of existing attributes by name for easy lookup
211
250
  attr_hash = {}
212
- existing_attrs.each { |attr| attr_hash[attr["Name"]] = attr }
251
+
252
+ # Handle different attribute structures between iDRAC versions
253
+ existing_attrs.each do |attr|
254
+ case attr
255
+ when Hash
256
+ # iDRAC 8 style: {"Name" => "Users.3#IpmiLanPrivilege", "Value" => "Administrator"}
257
+ attr_hash[attr["Name"]] = attr if attr["Name"]
258
+ when String
259
+ # iDRAC 9 style: strings or different structure - preserve as-is
260
+ # For strings, use the string itself as both key and value
261
+ attr_hash[attr] = attr
262
+ else
263
+ # Unknown structure, preserve as-is with a generated key
264
+ attr_hash["attr_#{attr_hash.size}"] = attr
265
+ end
266
+ end
213
267
 
214
268
  # Add/overwrite with new attributes
215
- new_attrs.each { |attr| attr_hash[attr["Name"]] = attr }
269
+ new_attrs.each do |attr|
270
+ case attr
271
+ when Hash
272
+ attr_hash[attr["Name"]] = attr if attr["Name"]
273
+ when String
274
+ attr_hash[attr] = attr
275
+ else
276
+ attr_hash["attr_#{attr_hash.size}"] = attr
277
+ end
278
+ end
216
279
 
217
280
  merged_components[fqdd]["Attributes"] = attr_hash.values
218
281
  else
@@ -225,18 +288,24 @@ module IDRAC
225
288
  end
226
289
 
227
290
  # Handle location header for IP change operations. Monitors old IP until it fails,
228
- # then aggressively monitors new IP with tight timeouts.
291
+ # then monitors job completion at new IP with proper task polling.
229
292
  def handle_location_with_ip_change(location, new_ip, timeout: 300)
230
293
  return nil if location.nil? || location.empty?
231
294
 
295
+ # Extract job ID from location header
296
+ job_id = location.split("/").last
297
+ debug "Extracted job ID: #{job_id}", 1, :cyan
298
+
232
299
  old_ip = @host
233
300
  start_time = Time.now
234
301
  old_ip_failed = false
302
+ task = nil
303
+ tries = 0
235
304
 
236
- debug "Monitoring IP change: #{old_ip} → #{new_ip}", 1, :blue
305
+ debug "Monitoring IP change with job tracking: #{old_ip} → #{new_ip}", 1, :blue
237
306
 
238
307
  while Time.now - start_time < timeout
239
- # Try old IP until it fails, then focus on new IP
308
+ # Try old IP until it fails, then focus on new IP with job monitoring
240
309
  [
241
310
  old_ip_failed ? nil : [self, old_ip, "Old IP failed"],
242
311
  [create_temp_client(new_ip), new_ip, old_ip_failed ? "New IP not ready" : "Cannot reach new IP"]
@@ -244,13 +313,45 @@ module IDRAC
244
313
 
245
314
  begin
246
315
  client.login if ip == new_ip
316
+
317
+ # Test basic connectivity first
247
318
  client.authenticated_request(:get, "/redfish/v1", timeout: 2, open_timeout: 1)
248
319
 
249
320
  if ip == new_ip
250
- debug "✅ IP change successful!", 1, :green
251
- @host = new_ip
252
- return { status: :success, ip: new_ip }
321
+ # Once we can reach the new IP, check the job status
322
+ debug "✅ New IP reachable, checking job status...", 1, :green
323
+ begin
324
+ task_response = client.authenticated_request(:get, "/redfish/v1/TaskService/Tasks/#{job_id}", timeout: 10)
325
+ task = JSON.parse(task_response.body)
326
+
327
+ debug "Job status: #{task['TaskState']} / #{task['TaskStatus']}", 1, :cyan
328
+
329
+ if task["TaskState"] == "Completed"
330
+ if task["TaskStatus"] == "OK"
331
+ debug "✅ Job completed successfully!", 1, :green
332
+ @host = new_ip
333
+ return { status: :success, ip: new_ip, job_status: task }
334
+ else
335
+ # Job completed but with error
336
+ msg = task['Messages']&.first&.dig('Message') rescue "N/A"
337
+ attr = task['Messages']&.first&.dig('Oem', 'Dell', 'Name') rescue "N/A"
338
+ error_msg = "Job failed: #{msg} : #{attr}, TaskState: #{task['TaskState']}, TaskStatus: #{task['TaskStatus']}"
339
+ debug "❌ #{error_msg}", 1, :red
340
+ return { status: :error, error: error_msg, job_status: task }
341
+ end
342
+ elsif task["TaskState"] == "Running"
343
+ debug "⏳ Job still running, continuing to wait...", 2, :yellow
344
+ # Continue monitoring
345
+ else
346
+ debug "⚠️ Unexpected job state: #{task['TaskState']}", 1, :yellow
347
+ # Continue monitoring
348
+ end
349
+ rescue => job_error
350
+ debug "Failed to check job status: #{job_error.message}", 2, :yellow
351
+ # Continue monitoring - job might not be ready yet
352
+ end
253
353
  else
354
+ # Still on old IP, just test connectivity
254
355
  return { status: :success, ip: old_ip }
255
356
  end
256
357
  rescue => e
@@ -259,7 +360,12 @@ module IDRAC
259
360
  end
260
361
  end
261
362
 
262
- sleep old_ip_failed ? 2 : 5
363
+ tries += 1
364
+ if tries > 20
365
+ return { status: :timeout, error: "Job monitoring exceeded maximum retries (#{tries})" }
366
+ end
367
+
368
+ sleep old_ip_failed ? 6 : 5 # Wait longer during IP change
263
369
  end
264
370
 
265
371
  { status: :timeout, error: "IP change timed out after #{timeout}s. Old IP failed: #{old_ip_failed}" }
data/lib/idrac/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module IDRAC
4
- VERSION = "0.8.6"
4
+ VERSION = "0.8.7"
5
5
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: idrac
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.6
4
+ version: 0.8.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jonathan Siegel
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2025-09-18 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: httparty
@@ -254,7 +255,6 @@ files:
254
255
  - README.md
255
256
  - bin/console
256
257
  - bin/idrac
257
- - bin/idrac-tsr
258
258
  - bin/setup
259
259
  - idrac.gemspec
260
260
  - lib/idrac.rb
@@ -282,6 +282,7 @@ licenses:
282
282
  metadata:
283
283
  homepage_uri: https://github.com/buildio/idrac
284
284
  source_code_uri: https://github.com/buildio/idrac
285
+ post_install_message:
285
286
  rdoc_options: []
286
287
  require_paths:
287
288
  - lib
@@ -296,7 +297,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
296
297
  - !ruby/object:Gem::Version
297
298
  version: '0'
298
299
  requirements: []
299
- rubygems_version: 3.6.7
300
+ rubygems_version: 3.5.16
301
+ signing_key:
300
302
  specification_version: 4
301
303
  summary: API Client for Dell iDRAC
302
304
  test_files: []
data/bin/idrac-tsr DELETED
@@ -1,90 +0,0 @@
1
- #!/usr/bin/env ruby
2
-
3
- require_relative '../lib/idrac'
4
- require 'optparse'
5
-
6
- options = {
7
- host: ENV['IDRAC_HOST'],
8
- username: ENV['IDRAC_USER'] || 'root',
9
- password: ENV['IDRAC_PASSWORD'] || 'calvin',
10
- output: nil,
11
- data_types: [0, 1, 2], # Default: Hardware, OS, Debug
12
- timeout: 600,
13
- verbose: false
14
- }
15
-
16
- OptionParser.new do |opts|
17
- opts.banner = "Usage: idrac-tsr [options]"
18
-
19
- opts.on("-h", "--host HOST", "iDRAC host address") do |h|
20
- options[:host] = h
21
- end
22
-
23
- opts.on("-u", "--username USER", "Username (default: root)") do |u|
24
- options[:username] = u
25
- end
26
-
27
- opts.on("-p", "--password PASS", "Password (default: calvin)") do |p|
28
- options[:password] = p
29
- end
30
-
31
- opts.on("-o", "--output FILE", "Output filename (default: auto-generated)") do |o|
32
- options[:output] = o
33
- end
34
-
35
- opts.on("-d", "--data TYPES", "Data types to collect (comma-separated: 0=HW,1=OS,2=Debug,3=TTY,4=All)") do |d|
36
- options[:data_types] = d.split(',').map(&:to_i)
37
- end
38
-
39
- opts.on("-t", "--timeout SECONDS", Integer, "Timeout in seconds (default: 600)") do |t|
40
- options[:timeout] = t
41
- end
42
-
43
- opts.on("-v", "--verbose", "Verbose output") do
44
- options[:verbose] = true
45
- end
46
-
47
- opts.on("--help", "Show this message") do
48
- puts opts
49
- exit
50
- end
51
- end.parse!
52
-
53
- if options[:host].nil?
54
- puts "Error: Host is required. Use -h HOST or set IDRAC_HOST environment variable"
55
- exit 1
56
- end
57
-
58
- begin
59
- client = IDRAC::Client.new(
60
- host: options[:host],
61
- username: options[:username],
62
- password: options[:password],
63
- verify_ssl: false
64
- )
65
-
66
- client.verbosity = options[:verbose] ? 1 : 0
67
-
68
- puts "Connecting to #{options[:host]}..."
69
- client.login
70
-
71
- puts "Generating TSR logs (this may take several minutes)..."
72
- result = client.generate_and_download_tsr(
73
- output_file: options[:output],
74
- data_selector_values: options[:data_types],
75
- wait_timeout: options[:timeout]
76
- )
77
-
78
- if result
79
- puts "✓ TSR logs saved to: #{result}"
80
- puts " File size: #{File.size(result) / 1024 / 1024} MB" if File.exist?(result)
81
- else
82
- puts "✗ Failed to download TSR logs"
83
- exit 1
84
- end
85
-
86
- client.logout
87
- rescue => e
88
- puts "Error: #{e.message}"
89
- exit 1
90
- end