idrac 0.8.6 → 0.9.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: ac429e71f14867043a891ec594d159e49210c4662f6c8993273423fc47470aea
4
- data.tar.gz: e12a69a35a3846944b7868a301c9d59210afd855f8fd6a7ab59bb4a2f7f07140
3
+ metadata.gz: 40d8abda65007546224a817d15c735d6b0f4fb35f7d4aba6a429bf1f4d92bc98
4
+ data.tar.gz: 8a33504cada67707e4f5d38ea8af248d68c6de3d91bf9f03c867ec2042617c9b
5
5
  SHA512:
6
- metadata.gz: 26d106fec37924d587fbcc774e3d81a0a9a464b10fd68b831df60e08fcbcbfa8bec012e216cfa5ef13732f9198d5a2d64f6ac6c01393d6b088cd6d4d3ebc6eb8
7
- data.tar.gz: 1144098c052f5d7939ff0fbcda31273e5287718a27803fd597f5decc4a90a9a0aa909ad9857f70b77063bfece56716734acf970afa8cefca7c20adb73ead2558
6
+ metadata.gz: 451ec8c73047bb0af4abf66e6b8139b7c84ea4f5e94c3391aa791b669c5e7cd95bc6aaa980ea0b37978b7f464cf6b9598072efdd767a4714bd542d1345f705cc
7
+ data.tar.gz: 98772256156d10521bffc87e752ef2249d35c865af29a3ad74743af283dd74ac0fbb19cff18767978fa06b2ef556dc57ee847b74381f6360ab462d829d021295
data/README.md CHANGED
@@ -291,6 +291,39 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
291
291
 
292
292
  ## Changelog
293
293
 
294
+ ### Version 0.9.0
295
+ - **Automatic Retry Handling**: ServiceTemporarilyUnavailable (503) errors now automatically retry with iDRAC-specified delay
296
+ - **Simplified API**: `authenticated_request` now returns response body string by default instead of response object
297
+ - Use block syntax `authenticated_request(...) { |response| ... }` for custom response handling
298
+ - Most methods are now simpler and more consistent across the codebase
299
+ - **Automatic Content-Type Header**: JSON request bodies automatically get `Content-Type: application/json` header
300
+ - **Automatic Job Monitoring**: Location headers for async operations are automatically handled with job/task monitoring
301
+ - **Fixed Jobs Module**: Resolved NoMethodError in `jobs`, `jobs_detail`, `clear_jobs!`, `force_clear_jobs!`, `wait_for_job`, and `tasks` methods
302
+ - **Improved Error Handling**: Job failures now raise immediately instead of retrying indefinitely
303
+ - **SSL Warning**: SSL verification warning now only shows in verbose mode
304
+ - **Code Cleanup**: Removed ~60 lines of redundant Content-Type headers and manual location handling
305
+ - **Comprehensive Tests**: Added full test coverage for Jobs module (63 total tests passing)
306
+ - **Ruby 3.4+ Ready**: Added `csv` and `ostruct` gems to prevent future deprecation warnings
307
+
308
+ ### Versions 0.8.1-0.8.7
309
+ - **Enhanced Hardware Support**: Added updates for latest Dell PowerEdge R7525 models
310
+ - **Storage Improvements**: Fixed controller model extraction from StorageControllers array
311
+ - **PSU Enhancements**:
312
+ - Fixed PSU voltage type display to show actual server values
313
+ - Added `power_supply_type` attribute
314
+ - Added `power_consumption_watts` alias for cross-vendor uniformity
315
+ - Ensured drives/volumes require controller_id parameter
316
+ - **BMC/iDRAC IP Configuration**: Added vendor-agnostic BMC IP setting functionality
317
+ - **API Consistency**: Enhanced API consistency and functionality across all modules
318
+ - **Test Suite**: Fixed TSR logs test suite with proper mocking
319
+ - **Dependency Updates**:
320
+ - Upgraded to Thor 1.4.0 (from 1.2.2)
321
+ - Updated ActiveSupport for Rails 8 compatibility
322
+ - Bumped Nokogiri to 1.18.9 for security updates
323
+ - **Bug Fixes**:
324
+ - Fixed nagging multi-release attempts
325
+ - Resolved various dependency issues
326
+
294
327
  ### Version 0.8.0
295
328
  - **Added TSR/SupportAssist Collection Support**: Simplified commands for generating Technical Support Reports
296
329
  - Generate and download SupportAssist collections with direct file download
data/bin/idrac CHANGED
@@ -1746,8 +1746,8 @@ module IDRAC
1746
1746
  end
1747
1747
 
1748
1748
  def check_ssl_verification
1749
- # If verify_ssl is not explicitly set in the command line, show a warning
1750
- unless ARGV.include?('--verify-ssl') || ARGV.include?('--no-verify-ssl')
1749
+ # If verify_ssl is not explicitly set in the command line, show a warning (only with verbose mode)
1750
+ if options[:verbose] && !(ARGV.include?('--verify-ssl') || ARGV.include?('--no-verify-ssl'))
1751
1751
  puts "WARNING: SSL verification is disabled by default. iDRAC typically uses self-signed certificates.".yellow
1752
1752
  puts " Use --verify-ssl if you want to enable SSL verification.".yellow
1753
1753
  puts ""
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
@@ -114,7 +114,23 @@ module IDRAC
114
114
  end
115
115
 
116
116
  # Send an authenticated request to the iDRAC
117
- def authenticated_request(method, path, body: nil, headers: {}, timeout: nil, open_timeout: nil, **options)
117
+ #
118
+ # Returns the full HTTParty::Response object by default, which allows access to:
119
+ # - response.status (HTTP status code)
120
+ # - response.body (response body as string)
121
+ # - response.headers (response headers)
122
+ #
123
+ # Automatically handles retry for 503 ServiceTemporarilyUnavailable errors.
124
+ # For error status codes (4xx, 5xx), handle_response is called to raise appropriate errors.
125
+ #
126
+ # You can provide a block for custom response handling:
127
+ # authenticated_request(:post, path) { |response| custom_logic(response) }
128
+ def authenticated_request(method, path, body: nil, headers: {}, timeout: nil, open_timeout: nil, **options, &block)
129
+ # Automatically set Content-Type for JSON requests if not already set
130
+ if body && body.is_a?(String) && !headers.key?('Content-Type') && !headers.key?(:content_type)
131
+ headers = headers.merge('Content-Type' => 'application/json')
132
+ end
133
+
118
134
  # Build options hash with all parameters
119
135
  request_options = {
120
136
  body: body,
@@ -122,9 +138,19 @@ module IDRAC
122
138
  timeout: timeout,
123
139
  open_timeout: open_timeout
124
140
  }.merge(options).compact
125
-
141
+
126
142
  with_retries do
127
- _perform_authenticated_request(method, path, request_options)
143
+ response = _perform_authenticated_request(method, path, request_options)
144
+
145
+ # If a block is provided, use it for custom response handling
146
+ if block_given?
147
+ yield response
148
+ else
149
+ # Call handle_response only for error status codes to enable retry logic
150
+ # This allows 503 errors to be caught and retried by with_retries
151
+ handle_response(response) if response.status >= 400
152
+ response # Return full response object for backward compatibility
153
+ end
128
154
  end
129
155
  end
130
156
 
@@ -193,6 +219,12 @@ module IDRAC
193
219
  original_timeout = conn.options.timeout
194
220
  original_open_timeout = conn.options.open_timeout
195
221
 
222
+ # Add host header if specified (needed for SSH tunnels to iDRAC)
223
+ if @host_header
224
+ headers = headers.merge('Host' => @host_header)
225
+ debug "Added Host header to iDRAC request: #{@host_header}", 2
226
+ end
227
+
196
228
  begin
197
229
  conn.options.timeout = timeout if timeout
198
230
  conn.options.open_timeout = open_timeout if open_timeout
@@ -360,6 +392,17 @@ module IDRAC
360
392
  retries = 0
361
393
  begin
362
394
  yield
395
+ rescue ServiceTemporarilyUnavailableError => e
396
+ retries += 1
397
+ if retries <= max_retries
398
+ delay = e.retry_delay # Use the delay specified by iDRAC
399
+ debug "🕒 IDRAC REQUESTED RETRY: ServiceTemporarilyUnavailable - Attempt #{retries}/#{max_retries}, waiting #{delay}s as instructed by iDRAC", 1, :cyan
400
+ sleep delay
401
+ retry
402
+ else
403
+ debug "MAX RETRIES REACHED: #{e.message} after #{max_retries} attempts", 1, :red
404
+ raise e
405
+ end
363
406
  rescue *error_classes => e
364
407
  retries += 1
365
408
  if retries <= max_retries
@@ -446,7 +489,54 @@ module IDRAC
446
489
  if response.status.between?(200, 299)
447
490
  return response.body
448
491
  else
449
- raise Error, "Failed to #{response.status} - #{response.body}"
492
+ # Enhanced error handling with ExtendedInfo support
493
+ error_message = "Failed with status #{response.status}"
494
+
495
+ begin
496
+ error_data = JSON.parse(response.body)
497
+
498
+ # Check for standard error message
499
+ if error_data['error'] && error_data['error']['message']
500
+ error_message += ": #{error_data['error']['message']}"
501
+ end
502
+
503
+ # Check for ExtendedInfo which contains detailed error information
504
+ if error_data['error'] && error_data['error']['@Message.ExtendedInfo']
505
+ extended_info = error_data['error']['@Message.ExtendedInfo']
506
+ if extended_info.is_a?(Array) && extended_info.any?
507
+ error_message += "\nExtendedInfo:"
508
+ retry_delay = nil
509
+ extended_info.each_with_index do |info, index|
510
+ error_message += "\n #{index + 1}. #{info['Message']}" if info['Message']
511
+ error_message += " (#{info['MessageId']})" if info['MessageId']
512
+ error_message += " - Resolution: #{info['Resolution']}" if info['Resolution']
513
+
514
+ # Check for ServiceTemporarilyUnavailable with retry delay
515
+ if info['MessageId'] == 'Base.1.12.ServiceTemporarilyUnavailable'
516
+ # Extract retry delay from MessageArgs (usually first argument)
517
+ if info['MessageArgs'] && info['MessageArgs'].any?
518
+ retry_delay = info['MessageArgs'].first.to_i
519
+ debug "🕒 iDRAC ServiceTemporarilyUnavailable detected - will wait #{retry_delay} seconds as requested", 1, :yellow
520
+ end
521
+ end
522
+ end
523
+
524
+ # If we detected a ServiceTemporarilyUnavailable error, raise a special exception
525
+ if retry_delay
526
+ raise ServiceTemporarilyUnavailableError.new(error_message, retry_delay)
527
+ end
528
+ end
529
+ end
530
+
531
+ # Also add the full response body for debugging
532
+ debug "Full error response: #{response.body}", 1, :red if @verbosity && @verbosity > 0
533
+
534
+ rescue JSON::ParserError => e
535
+ error_message += " - Raw response: #{response.body}"
536
+ debug "Failed to parse JSON error response: #{e.message}", 1, :yellow if @verbosity && @verbosity > 0
537
+ end
538
+
539
+ raise Error, error_message
450
540
  end
451
541
  end
452
542
 
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.9.0"
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.9.0
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-10-21 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