idrac 0.1.26 → 0.1.29

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: 4d751ffb6e590b80a6a5dc72457d8a4be11213224a13ebeae50698ae9f6cafff
4
- data.tar.gz: 5a3f826850bf9aee8e40287ff7f8ab7c5bd71944ddce2b183dbe88dabed9c3fa
3
+ metadata.gz: 6c080d98bd3ca44b9cc006ba66cc443ec410dcfb6bc1b712141720cc82998884
4
+ data.tar.gz: fe0e09d1e3089c5d354abf6f786624d5afd7dfbcd7b2984d84a72be9936dcf9c
5
5
  SHA512:
6
- metadata.gz: 553bef804c000680be8ecadb15c19f2b4a335db5a7a44b5071ccb230f168880268e906f5a296a70c8659a5bbbb14a0ffb3a6f56cfc4c9ca216e0a1f8a6024a0f
7
- data.tar.gz: ff4d0ce0967de6fbddd425844e0c86165276c44d508a2b8de41a20ec54e3998f7d2f1d5aa7cadf34201dd26113bd0abafb1e01d13439edbcb1d22f273e31a1f5
6
+ metadata.gz: 479b4ceb21686d4803a3f780f8fe19fcd1741f17cf3b1e24cd0039bd7758688810260dd939fe60cfe07b3b829feefcfbeee4953826f315b65707d2467121fa6c
7
+ data.tar.gz: 1855a7715e1267c505cc20f9dfd9a9e0665b78ac4db5b06a6984503bb88fb849e7f41b9ebeb68b6e67a70d0b663ea3a6f9b7bbecd1e4c5e194aff198ca9b5238
data/README.md CHANGED
@@ -37,8 +37,10 @@ idrac screenshot --host=192.168.1.100 --username=root --password=calvin
37
37
  # Specify a custom output filename
38
38
  idrac screenshot --host=192.168.1.100 --username=root --password=calvin --output=my_screenshot.png
39
39
 
40
- # Download the Dell firmware catalog
41
- idrac firmware:catalog --host=192.168.1.100 --username=root --password=calvin
40
+ # Download the Dell firmware catalog (no host required)
41
+ idrac catalog download
42
+ # or
43
+ idrac firmware:catalog
42
44
 
43
45
  # Check firmware status and available updates
44
46
  idrac firmware:status --host=192.168.1.100 --username=root --password=calvin
@@ -93,8 +95,9 @@ puts "Screenshot saved to: #{filename}"
93
95
  # Firmware operations
94
96
  firmware = IDRAC::Firmware.new(client)
95
97
 
96
- # Download catalog
97
- catalog_path = firmware.download_catalog
98
+ # Download catalog (no client required)
99
+ catalog = IDRAC::FirmwareCatalog.new
100
+ catalog_path = catalog.download
98
101
 
99
102
  # Get system inventory
100
103
  inventory = firmware.get_system_inventory
@@ -127,6 +130,21 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
127
130
 
128
131
  ## Changelog
129
132
 
133
+ ### Version 0.1.28
134
+ - **Improved Firmware Update Checking**: Completely redesigned the firmware update checking process
135
+ - Added a dedicated `FirmwareCatalog` class for better separation of concerns
136
+ - Improved component matching with more accurate detection of available updates
137
+ - Enhanced output format with a tabular display showing component details, versions, and update status
138
+ - Added system model detection for more accurate firmware matching
139
+ - Improved version comparison logic for different version formats (numeric, Dell A00 format, etc.)
140
+ - Better handling of network adapters and other component types
141
+
142
+ ### Version 0.1.27
143
+ - **Removed Host Requirement for Catalog Download**: The `catalog download` and `firmware:catalog` commands no longer require the `--host` parameter
144
+ - Added a dedicated `catalog` command that can be used directly: `idrac catalog download`
145
+ - The catalog download functionality can now be used without an iDRAC connection
146
+ - Updated the Ruby API to support catalog downloads without a client
147
+
130
148
  ### Version 0.1.26
131
149
  - **Improved Redfish Session Creation**: Fixed issues with the Redfish session creation process
132
150
  - Added multiple fallback methods for creating sessions with different iDRAC versions
data/bin/idrac CHANGED
@@ -13,7 +13,8 @@ require "idrac"
13
13
 
14
14
  module IDRAC
15
15
  class CLI < Thor
16
- class_option :host, type: :string, required: true, desc: "iDRAC host address"
16
+ # Make host not required for all commands
17
+ class_option :host, type: :string, required: false, desc: "iDRAC host address"
17
18
  class_option :username, type: :string, required: false, default: "root", desc: "iDRAC username (default: root)"
18
19
  class_option :password, type: :string, required: false, default: "calvin", desc: "iDRAC password (default: calvin)"
19
20
  class_option :port, type: :numeric, default: 443, desc: "iDRAC port"
@@ -25,6 +26,9 @@ module IDRAC
25
26
  method_option :wait, type: :boolean, default: true, desc: "Wait for the update to complete"
26
27
  method_option :timeout, type: :numeric, default: 3600, desc: "Timeout in seconds when waiting"
27
28
  def firmware_update(path)
29
+ # Ensure host is provided for commands that need it
30
+ ensure_host_provided
31
+
28
32
  check_ssl_verification
29
33
  client = create_client
30
34
  firmware = IDRAC::Firmware.new(client)
@@ -42,24 +46,25 @@ module IDRAC
42
46
 
43
47
  desc "firmware:catalog [DIRECTORY]", "Download Dell firmware catalog"
44
48
  def firmware_catalog(directory = nil)
45
- check_ssl_verification
46
- client = create_client
47
- firmware = IDRAC::Firmware.new(client)
49
+ # This command doesn't require a host
50
+ # Create a FirmwareCatalog instance directly
51
+ catalog = IDRAC::FirmwareCatalog.new
48
52
 
49
53
  begin
50
- catalog_path = firmware.download_catalog(directory)
54
+ catalog_path = catalog.download(directory)
51
55
  puts "Catalog downloaded to: #{catalog_path}"
52
56
  rescue IDRAC::Error => e
53
57
  puts "Error: #{e.message}"
54
58
  exit 1
55
- ensure
56
- client.logout
57
59
  end
58
60
  end
59
61
 
60
62
  desc "firmware:status", "Show current firmware status and available updates"
61
63
  method_option :catalog, type: :string, desc: "Path to existing catalog file"
62
64
  def firmware_status
65
+ # Ensure host is provided for commands that need it
66
+ ensure_host_provided
67
+
63
68
  check_ssl_verification
64
69
  client = create_client
65
70
  firmware = IDRAC::Firmware.new(client)
@@ -85,17 +90,12 @@ module IDRAC
85
90
  # Check for updates if catalog is available
86
91
  if options[:catalog] || File.exist?(default_catalog)
87
92
  catalog_path = options[:catalog] || default_catalog
88
- puts "\nChecking for updates using catalog: #{catalog_path}"
89
93
 
94
+ # Check for updates using the firmware class
90
95
  updates = firmware.check_updates(catalog_path)
91
96
 
92
97
  if updates.empty?
93
98
  puts "No updates available."
94
- else
95
- puts "\nAvailable Updates:"
96
- updates.each do |update|
97
- puts " #{update[:name]}: #{update[:current_version]} -> #{update[:available_version]}"
98
- end
99
99
  end
100
100
  else
101
101
  puts "\nTo check for updates, download the catalog first with 'idrac firmware:catalog'"
@@ -111,6 +111,9 @@ module IDRAC
111
111
  desc "firmware:interactive", "Interactive firmware update"
112
112
  method_option :catalog, type: :string, desc: "Path to existing catalog file"
113
113
  def firmware_interactive
114
+ # Ensure host is provided for commands that need it
115
+ ensure_host_provided
116
+
114
117
  check_ssl_verification
115
118
  client = create_client
116
119
  firmware = IDRAC::Firmware.new(client)
@@ -129,11 +132,40 @@ module IDRAC
129
132
  # If still no catalog, download it
130
133
  if catalog_path.nil?
131
134
  puts "No catalog found. Downloading..."
132
- catalog_path = firmware.download_catalog
135
+ catalog = IDRAC::FirmwareCatalog.new
136
+ catalog_path = catalog.download
133
137
  end
134
138
 
135
- firmware.interactive_update(catalog_path)
136
- rescue IDRAC::Error => e
139
+ puts "Starting interactive firmware update. Please note:"
140
+ puts "- The iDRAC can only process one firmware update at a time"
141
+ puts "- Updates may take several minutes to complete"
142
+ puts "- For BIOS updates, a server reboot will be required to apply the update"
143
+ puts "- If you encounter errors, check the iDRAC web interface for active jobs"
144
+ puts ""
145
+
146
+ begin
147
+ firmware.interactive_update(catalog_path)
148
+ rescue ArgumentError => e
149
+ puts "Error: #{e.message}"
150
+ puts "This could be due to an issue with the interactive update process."
151
+ puts "If you're seeing a 'job ID not found' error, it might be because:"
152
+ puts "1. The firmware update job wasn't created properly"
153
+ puts "2. There's already an update in progress"
154
+ puts "3. The iDRAC needs time to process the previous request"
155
+ puts "\nTry again in a few minutes or check the iDRAC web interface for active jobs."
156
+ rescue IDRAC::Error => e
157
+ if e.message.include?("already in progress")
158
+ puts "Error: #{e.message}"
159
+ puts "\nTroubleshooting steps:"
160
+ puts "1. Check the iDRAC web interface under Maintenance > System Update for active jobs"
161
+ puts "2. Wait for any existing updates to complete (can take 15-30 minutes)"
162
+ puts "3. If no updates appear to be in progress, you may need to restart the iDRAC"
163
+ puts " (iDRAC web interface > Settings > iDRAC Settings > Reset iDRAC)"
164
+ else
165
+ puts "Error: #{e.message}"
166
+ end
167
+ end
168
+ rescue => e
137
169
  puts "Error: #{e.message}"
138
170
  exit 1
139
171
  ensure
@@ -144,6 +176,9 @@ module IDRAC
144
176
  desc "screenshot", "Take a screenshot of the current iDRAC console"
145
177
  method_option :output, type: :string, desc: "Output filename (default: idrac_screenshot_timestamp.png)"
146
178
  def screenshot
179
+ # Ensure host is provided for commands that need it
180
+ ensure_host_provided
181
+
147
182
  check_ssl_verification
148
183
  client = create_client
149
184
 
@@ -175,6 +210,13 @@ module IDRAC
175
210
 
176
211
  private
177
212
 
213
+ def ensure_host_provided
214
+ unless options[:host]
215
+ puts "Error: No value provided for required option '--host'"
216
+ exit 1
217
+ end
218
+ end
219
+
178
220
  def check_ssl_verification
179
221
  # If verify_ssl is not explicitly set in the command line, show a warning
180
222
  unless ARGV.include?('--verify-ssl') || ARGV.include?('--no-verify-ssl')
@@ -198,4 +240,5 @@ module IDRAC
198
240
  end
199
241
  end
200
242
 
243
+ # Start the CLI
201
244
  IDRAC::CLI.start(ARGV)
@@ -0,0 +1,3 @@
1
+ module IDRAC
2
+ class Error < StandardError; end
3
+ end
@@ -5,6 +5,8 @@ require 'json'
5
5
  require 'nokogiri'
6
6
  require 'fileutils'
7
7
  require 'securerandom'
8
+ require 'set'
9
+ require_relative 'firmware_catalog'
8
10
 
9
11
  module IDRAC
10
12
  class Firmware
@@ -22,6 +24,9 @@ module IDRAC
22
24
  raise Error, "Firmware file not found: #{firmware_path}"
23
25
  end
24
26
 
27
+ # Ensure we have a client
28
+ raise Error, "Client is required for firmware update" unless client
29
+
25
30
  # Login to iDRAC
26
31
  client.login unless client.instance_variable_get(:@session_id)
27
32
 
@@ -37,43 +42,15 @@ module IDRAC
37
42
  end
38
43
 
39
44
  def download_catalog(output_dir = nil)
40
- # Use ~/.idrac as the default directory
41
- output_dir ||= File.expand_path("~/.idrac")
42
- FileUtils.mkdir_p(output_dir) unless Dir.exist?(output_dir)
43
-
44
- catalog_gz_path = File.join(output_dir, "Catalog.xml.gz")
45
- catalog_path = File.join(output_dir, "Catalog.xml")
46
-
47
- puts "Downloading Dell catalog from #{CATALOG_URL}..."
48
-
49
- uri = URI.parse(CATALOG_URL)
50
- Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
51
- request = Net::HTTP::Get.new(uri)
52
- http.request(request) do |response|
53
- if response.code == "200"
54
- File.open(catalog_gz_path, 'wb') do |file|
55
- response.read_body do |chunk|
56
- file.write(chunk)
57
- end
58
- end
59
- else
60
- raise Error, "Failed to download catalog: #{response.code} #{response.message}"
61
- end
62
- end
63
- end
64
-
65
- puts "Extracting catalog..."
66
- system("gunzip -f #{catalog_gz_path}")
67
-
68
- if File.exist?(catalog_path)
69
- puts "Catalog downloaded and extracted to #{catalog_path}"
70
- return catalog_path
71
- else
72
- raise Error, "Failed to extract catalog"
73
- end
45
+ # Use the new FirmwareCatalog class
46
+ catalog = FirmwareCatalog.new
47
+ catalog.download(output_dir)
74
48
  end
75
49
 
76
50
  def get_system_inventory
51
+ # Ensure we have a client
52
+ raise Error, "Client is required for system inventory" unless client
53
+
77
54
  puts "Retrieving system inventory..."
78
55
 
79
56
  # Get basic system information
@@ -133,150 +110,172 @@ module IDRAC
133
110
  end
134
111
 
135
112
  def check_updates(catalog_path = nil)
113
+ # Ensure we have a client for system inventory
114
+ raise Error, "Client is required for checking updates" unless client
115
+
136
116
  # Download catalog if not provided
137
117
  catalog_path ||= download_catalog
138
118
 
139
119
  # Get system inventory
140
120
  inventory = get_system_inventory
141
121
 
142
- # Parse catalog
143
- catalog_doc = File.open(catalog_path) { |f| Nokogiri::XML(f) }
122
+ # Create a FirmwareCatalog instance
123
+ catalog = FirmwareCatalog.new(catalog_path)
144
124
 
145
- # Extract service tag
125
+ # Extract system information
126
+ system_model = inventory[:system][:model]
146
127
  service_tag = inventory[:system][:service_tag]
147
128
 
148
129
  puts "Checking updates for system with service tag: #{service_tag}"
130
+ puts "Searching for updates for model: #{system_model}"
149
131
 
150
- # Find applicable updates
151
- updates = []
132
+ # Find system models in the catalog
133
+ models = catalog.find_system_models(system_model)
152
134
 
153
- # Get current firmware versions
154
- current_versions = {}
155
- inventory[:firmware].each do |fw|
156
- # Use the ID as the key to avoid duplicates
157
- current_versions[fw[:id]] = {
158
- name: fw[:name],
159
- version: fw[:version],
160
- updateable: fw[:updateable],
161
- identifiers: extract_identifiers(fw[:name]) # Extract identifiers for better matching
162
- }
135
+ if models.empty?
136
+ puts "No matching system model found in catalog"
137
+ return []
163
138
  end
164
139
 
165
- # Find matching components in catalog
166
- catalog_doc.xpath('//SoftwareComponent').each do |component|
167
- name = component.at_xpath('Name')&.text
168
- version = component.at_xpath('Version')&.text
169
- path = component.at_xpath('Path')&.text
170
- component_type = component.at_xpath('ComponentType')&.text
171
-
172
- next unless name && version && path # Skip if missing essential data
140
+ # Use the first matching model
141
+ model = models.first
142
+ puts "Found system IDs for #{model[:name]}: #{model[:id]}"
143
+
144
+ # Find updates for this system
145
+ catalog_updates = catalog.find_updates_for_system(model[:id])
146
+ puts "Found #{catalog_updates.size} firmware updates for #{model[:name]}"
147
+
148
+ # Compare current firmware with available updates
149
+ updates = []
150
+
151
+ # Print header for firmware comparison table
152
+ puts "\nFirmware Version Comparison:"
153
+ puts "=" * 100
154
+ puts "%-30s %-20s %-20s %-10s %-15s %s" % ["Component", "Current Version", "Available Version", "Updateable", "Category", "Status"]
155
+ puts "-" * 100
156
+
157
+ # Track components we've already displayed to avoid duplicates
158
+ displayed_components = Set.new
159
+
160
+ # First show current firmware with available updates
161
+ inventory[:firmware].each do |fw|
162
+ # Make sure firmware name is not nil
163
+ firmware_name = fw[:name] || ""
173
164
 
174
- # Extract identifiers from catalog component name for better matching
175
- catalog_identifiers = extract_identifiers(name)
165
+ # Skip if we've already displayed this component
166
+ next if displayed_components.include?(firmware_name.downcase)
167
+ displayed_components.add(firmware_name.downcase)
176
168
 
177
- # Check if this component matches any of our firmware
178
- # We'll track if we found a match to avoid duplicates
179
- matched = false
169
+ # Extract key identifiers from the firmware name
170
+ identifiers = extract_identifiers(firmware_name)
180
171
 
181
- current_versions.each do |id, fw_info|
182
- # Skip if not updateable
183
- next unless fw_info[:updateable]
184
-
185
- # Normalize names for comparison
186
- catalog_name = name.downcase.strip
187
- firmware_name = fw_info[:name].downcase.strip
188
-
189
- # Check for matches using multiple strategies
190
- match_found = false
191
-
192
- # 1. Check if names contain each other
193
- if catalog_name.include?(firmware_name) || firmware_name.include?(catalog_name)
194
- match_found = true
195
- end
172
+ # Try to find a matching update
173
+ matching_updates = catalog_updates.select do |update|
174
+ update_name = update[:name] || ""
196
175
 
197
- # 2. Check if BIOS components match
198
- if (catalog_name.include?("bios") && firmware_name.include?("bios"))
199
- match_found = true
200
- end
176
+ # Check if any of our identifiers match the update name
177
+ identifiers.any? { |id| update_name.downcase.include?(id.downcase) } ||
178
+ # Or if the update name contains the firmware name
179
+ update_name.downcase.include?(firmware_name.downcase) ||
180
+ # Or if the firmware name contains the update name
181
+ firmware_name.downcase.include?(update_name.downcase)
182
+ end
183
+
184
+ if matching_updates.any?
185
+ # Use the first matching update
186
+ update = matching_updates.first
201
187
 
202
- # 3. Check if identifiers match
203
- if !match_found && !fw_info[:identifiers].empty? && !catalog_identifiers.empty?
204
- # Check if any identifier from firmware matches any identifier from catalog
205
- if (fw_info[:identifiers] & catalog_identifiers).any?
206
- match_found = true
207
- end
208
- end
188
+ # Check if version is newer
189
+ needs_update = catalog.compare_versions(fw[:version], update[:version])
209
190
 
210
- # If we found a match and versions differ, add to updates
211
- if match_found && !matched && version != fw_info[:version]
191
+ # Add to updates list if needed
192
+ if needs_update && fw[:updateable]
212
193
  updates << {
213
- name: name,
214
- current_version: fw_info[:version],
215
- available_version: version,
216
- path: path,
217
- component_type: component_type,
218
- download_url: "https://downloads.dell.com/#{path}"
194
+ name: fw[:name],
195
+ current_version: fw[:version],
196
+ available_version: update[:version],
197
+ path: update[:path],
198
+ component_type: update[:component_type],
199
+ category: update[:category],
200
+ download_url: update[:download_url]
219
201
  }
220
202
 
221
- # Mark as matched to avoid duplicates
222
- matched = true
203
+ # Print row with update available
204
+ puts "%-30s %-20s %-20s %-10s %-15s %s" % [
205
+ fw[:name].to_s[0..29],
206
+ fw[:version],
207
+ update[:version],
208
+ fw[:updateable] ? "Yes" : "No",
209
+ update[:category] || "N/A",
210
+ "UPDATE AVAILABLE"
211
+ ]
212
+ else
213
+ # Print row with no update needed
214
+ status = if !needs_update
215
+ "Current"
216
+ elsif !fw[:updateable]
217
+ "Not updateable"
218
+ else
219
+ "No update needed"
220
+ end
221
+
222
+ puts "%-30s %-20s %-20s %-10s %-15s %s" % [
223
+ fw[:name].to_s[0..29],
224
+ fw[:version],
225
+ update[:version] || "N/A",
226
+ fw[:updateable] ? "Yes" : "No",
227
+ update[:category] || "N/A",
228
+ status
229
+ ]
223
230
  end
231
+ else
232
+ # No matching update found
233
+ puts "%-30s %-20s %-20s %-10s %-15s %s" % [
234
+ fw[:name].to_s[0..29],
235
+ fw[:version],
236
+ "N/A",
237
+ fw[:updateable] ? "Yes" : "No",
238
+ "N/A",
239
+ "No update available"
240
+ ]
224
241
  end
225
242
  end
226
243
 
227
- updates
228
- end
229
-
230
- def extract_identifiers(name)
231
- return [] unless name
232
-
233
- identifiers = []
234
-
235
- # Extract model numbers like X520, I350, etc.
236
- model_matches = name.scan(/[IX]\d{3,4}/)
237
- identifiers.concat(model_matches)
238
-
239
- # Extract PERC model like H730
240
- perc_matches = name.scan(/[HP]\d{3,4}/)
241
- identifiers.concat(perc_matches)
242
-
243
- # Extract other common identifiers
244
- if name.include?("NIC") || name.include?("Ethernet") || name.include?("Network")
245
- identifiers << "NIC"
246
- end
247
-
248
- if name.include?("PERC") || name.include?("RAID")
249
- identifiers << "PERC"
250
- # Extract PERC model like H730
251
- perc_match = name.match(/PERC\s+([A-Z]\d{3})/)
252
- identifiers << perc_match[1] if perc_match
253
- end
254
-
255
- if name.include?("BIOS")
256
- identifiers << "BIOS"
257
- end
258
-
259
- if name.include?("iDRAC") || name.include?("IDRAC") || name.include?("Remote Access Controller")
260
- identifiers << "iDRAC"
261
- end
262
-
263
- if name.include?("Power Supply") || name.include?("PSU")
264
- identifiers << "PSU"
265
- end
266
-
267
- if name.include?("Lifecycle Controller")
268
- identifiers << "LC"
244
+ # Then show available updates that don't match any current firmware
245
+ catalog_updates.each do |update|
246
+ update_name = update[:name] || ""
247
+
248
+ # Skip if we've already displayed this component
249
+ next if displayed_components.include?(update_name.downcase)
250
+ displayed_components.add(update_name.downcase)
251
+
252
+ # Skip if this update was already matched to a current firmware
253
+ next if inventory[:firmware].any? do |fw|
254
+ firmware_name = fw[:name] || ""
255
+ identifiers = extract_identifiers(firmware_name)
256
+
257
+ identifiers.any? { |id| update_name.downcase.include?(id.downcase) } ||
258
+ update_name.downcase.include?(firmware_name.downcase) ||
259
+ firmware_name.downcase.include?(update_name.downcase)
260
+ end
261
+
262
+ puts "%-30s %-20s %-20s %-10s %-15s %s" % [
263
+ update_name.to_s[0..29],
264
+ "Not Installed",
265
+ update[:version] || "Unknown",
266
+ "N/A",
267
+ update[:category] || "N/A",
268
+ "NEW COMPONENT"
269
+ ]
269
270
  end
270
271
 
271
- if name.include?("CPLD")
272
- identifiers << "CPLD"
273
- end
272
+ puts "=" * 100
274
273
 
275
- identifiers
274
+ updates
276
275
  end
277
276
 
278
- def interactive_update(catalog_path = nil)
279
- updates = check_updates(catalog_path)
277
+ def interactive_update(catalog_path = nil, selected_updates = nil)
278
+ updates = selected_updates || check_updates(catalog_path)
280
279
 
281
280
  if updates.empty?
282
281
  puts "No updates available for your system."
@@ -288,24 +287,30 @@ module IDRAC
288
287
  puts "#{index + 1}. #{update[:name]}: #{update[:current_version]} -> #{update[:available_version]}"
289
288
  end
290
289
 
291
- puts "\nEnter the number of the update to install (or 'all' for all updates, 'q' to quit):"
292
- choice = STDIN.gets.chomp
293
-
294
- return if choice.downcase == 'q'
295
-
296
- selected_updates = if choice.downcase == 'all'
297
- updates
298
- else
299
- index = choice.to_i - 1
300
- if index >= 0 && index < updates.size
301
- [updates[index]]
302
- else
303
- puts "Invalid selection."
304
- return
305
- end
306
- end
290
+ # If selected_updates is provided, use those directly
291
+ if selected_updates
292
+ selected = selected_updates
293
+ else
294
+ # Otherwise prompt the user for selection
295
+ puts "\nEnter the number of the update to install (or 'all' for all updates, 'q' to quit):"
296
+ choice = STDIN.gets.chomp
297
+
298
+ return if choice.downcase == 'q'
299
+
300
+ selected = if choice.downcase == 'all'
301
+ updates
302
+ else
303
+ index = choice.to_i - 1
304
+ if index >= 0 && index < updates.size
305
+ [updates[index]]
306
+ else
307
+ puts "Invalid selection."
308
+ return
309
+ end
310
+ end
311
+ end
307
312
 
308
- selected_updates.each do |update|
313
+ selected.each do |update|
309
314
  puts "Downloading #{update[:name]} version #{update[:available_version]}..."
310
315
 
311
316
  # Create temp directory
@@ -334,8 +339,28 @@ module IDRAC
334
339
  end
335
340
 
336
341
  puts "Installing #{update[:name]} version #{update[:available_version]}..."
337
- job_id = update(update_path, wait: true)
338
- puts "Update completed with job ID: #{job_id}"
342
+
343
+ begin
344
+ job_id = update(update_path, wait: true)
345
+ puts "Update completed with job ID: #{job_id}"
346
+ rescue IDRAC::Error => e
347
+ if e.message.include?("already in progress")
348
+ puts "Error: #{e.message}"
349
+ puts "\nTips for resolving this issue:"
350
+ puts "1. Wait for any existing firmware updates to complete (check iDRAC web interface)"
351
+ puts "2. Restart the iDRAC if no updates appear to be in progress (Settings > iDRAC Settings > Reset iDRAC)"
352
+ puts "3. Try again after a few minutes"
353
+ elsif e.message.include?("job ID") || e.message.include?("job not found")
354
+ puts "Error: #{e.message}"
355
+ puts "\nThe job ID could not be found or monitored. This could be because:"
356
+ puts "1. The iDRAC is busy processing other requests"
357
+ puts "2. The job was created but not properly tracked"
358
+ puts "3. The iDRAC firmware may need to be updated first"
359
+ puts "\nCheck the iDRAC web interface to see if the update was actually initiated."
360
+ else
361
+ puts "Error during firmware update: #{e.message}"
362
+ end
363
+ end
339
364
 
340
365
  ensure
341
366
  # Clean up temp directory
@@ -383,28 +408,103 @@ module IDRAC
383
408
  post_body << "\r\n--#{boundary}--\r\n"
384
409
 
385
410
  # Upload the firmware
386
- response = client.authenticated_request(
411
+ begin
412
+ response = client.authenticated_request(
413
+ :post,
414
+ http_push_uri,
415
+ {
416
+ headers: {
417
+ 'Content-Type' => "multipart/form-data; boundary=#{boundary}",
418
+ 'If-Match' => etag
419
+ },
420
+ body: post_body.join
421
+ }
422
+ )
423
+ rescue => e
424
+ # Check if the error is about a deployment already in progress
425
+ if e.message.include?("A deployment or update operation is already in progress")
426
+ raise Error, "A firmware update is already in progress on the iDRAC. Please wait for it to complete before starting another update. You can check the status in the iDRAC web interface under Maintenance > System Update."
427
+ else
428
+ # Re-raise the original error
429
+ raise
430
+ end
431
+ end
432
+
433
+ if response.status < 200 || response.status >= 300
434
+ error_message = response.body.to_s
435
+
436
+ # Check for specific error messages in the response
437
+ if error_message.include?("A deployment or update operation is already in progress")
438
+ raise Error, "A firmware update is already in progress on the iDRAC. Please wait for it to complete before starting another update. You can check the status in the iDRAC web interface under Maintenance > System Update."
439
+ elsif error_message.include?("SUP0108")
440
+ raise Error, "iDRAC Error SUP0108: A deployment or update operation is already in progress. Wait for the operation to conclude and then try again."
441
+ else
442
+ raise Error, "Firmware upload failed with status #{response.status}: #{response.body}"
443
+ end
444
+ end
445
+
446
+ # Extract upload ID from response
447
+ response_data = JSON.parse(response.body)
448
+ upload_id = response_data['Id'] || response_data['TaskId']
449
+
450
+ if upload_id.nil?
451
+ raise Error, "Failed to extract upload ID from firmware upload response"
452
+ end
453
+
454
+ puts "Firmware file uploaded successfully with ID: #{upload_id}"
455
+
456
+ # Step 2: Initiate the firmware update using SimpleUpdate
457
+ puts "Initiating firmware update using SimpleUpdate..."
458
+
459
+ # Construct the image URI using the uploaded file
460
+ image_uri = "#{http_push_uri}/#{upload_id}"
461
+ puts "Using ImageURI: #{image_uri}"
462
+
463
+ # Call the SimpleUpdate action
464
+ simple_update_response = client.authenticated_request(
387
465
  :post,
388
- http_push_uri,
466
+ "/redfish/v1/UpdateService/Actions/UpdateService.SimpleUpdate",
389
467
  {
390
468
  headers: {
391
- 'Content-Type' => "multipart/form-data; boundary=#{boundary}",
392
- 'If-Match' => etag
469
+ 'Content-Type' => 'application/json'
393
470
  },
394
- body: post_body.join
471
+ body: JSON.generate({
472
+ 'ImageURI' => image_uri,
473
+ '@Redfish.OperationApplyTime' => 'Immediate'
474
+ })
395
475
  }
396
476
  )
397
477
 
398
- if response.status < 200 || response.status >= 300
399
- raise Error, "Firmware upload failed with status #{response.status}: #{response.body}"
478
+ if simple_update_response.status < 200 || simple_update_response.status >= 300
479
+ raise Error, "Firmware update initiation failed with status #{simple_update_response.status}: #{simple_update_response.body}"
400
480
  end
401
481
 
402
482
  # Extract job ID from response
403
- response_data = JSON.parse(response.body)
404
- job_id = response_data['Id'] || response_data['TaskId']
483
+ job_id = nil
484
+
485
+ # Try to get job ID from Location header
486
+ if simple_update_response.headers['location']
487
+ job_id = simple_update_response.headers['location'].split('/').last
488
+ end
405
489
 
490
+ # If not found in header, try to get from response body
491
+ if job_id.nil? && !simple_update_response.body.empty?
492
+ begin
493
+ update_data = JSON.parse(simple_update_response.body)
494
+ if update_data['@odata.id']
495
+ job_id = update_data['@odata.id'].split('/').last
496
+ elsif update_data['Id']
497
+ job_id = update_data['Id']
498
+ end
499
+ rescue JSON::ParserError
500
+ # Not JSON, ignore
501
+ end
502
+ end
503
+
504
+ # If still no job ID, use the upload ID as a fallback
406
505
  if job_id.nil?
407
- raise Error, "Failed to extract job ID from firmware upload response"
506
+ job_id = upload_id
507
+ puts "No job ID found in SimpleUpdate response, using upload ID as job ID"
408
508
  end
409
509
 
410
510
  puts "Firmware update job created with ID: #{job_id}"
@@ -452,5 +552,54 @@ module IDRAC
452
552
  response_data = JSON.parse(response.body)
453
553
  response_data['TaskState'] || 'Unknown'
454
554
  end
555
+
556
+ # Helper method to extract identifiers from component names
557
+ def extract_identifiers(name)
558
+ return [] unless name
559
+
560
+ identifiers = []
561
+
562
+ # Extract model numbers like X520, I350, etc.
563
+ model_matches = name.scan(/[IX]\d{3,4}/)
564
+ identifiers.concat(model_matches)
565
+
566
+ # Extract PERC model like H730
567
+ perc_matches = name.scan(/[HP]\d{3,4}/)
568
+ identifiers.concat(perc_matches)
569
+
570
+ # Extract other common identifiers
571
+ if name.include?("NIC") || name.include?("Ethernet") || name.include?("Network")
572
+ identifiers << "NIC"
573
+ end
574
+
575
+ if name.include?("PERC") || name.include?("RAID")
576
+ identifiers << "PERC"
577
+ # Extract PERC model like H730
578
+ perc_match = name.match(/PERC\s+([A-Z]\d{3})/)
579
+ identifiers << perc_match[1] if perc_match
580
+ end
581
+
582
+ if name.include?("BIOS")
583
+ identifiers << "BIOS"
584
+ end
585
+
586
+ if name.include?("iDRAC") || name.include?("IDRAC") || name.include?("Remote Access Controller")
587
+ identifiers << "iDRAC"
588
+ end
589
+
590
+ if name.include?("Power Supply") || name.include?("PSU")
591
+ identifiers << "PSU"
592
+ end
593
+
594
+ if name.include?("Lifecycle Controller")
595
+ identifiers << "LC"
596
+ end
597
+
598
+ if name.include?("CPLD")
599
+ identifiers << "CPLD"
600
+ end
601
+
602
+ identifiers
603
+ end
455
604
  end
456
605
  end
@@ -0,0 +1,314 @@
1
+ require 'net/http'
2
+ require 'uri'
3
+ require 'fileutils'
4
+ require 'nokogiri'
5
+
6
+ module IDRAC
7
+ class FirmwareCatalog
8
+ CATALOG_URL = "https://downloads.dell.com/catalog/Catalog.xml.gz"
9
+
10
+ attr_reader :catalog_path
11
+
12
+ def initialize(catalog_path = nil)
13
+ @catalog_path = catalog_path
14
+ end
15
+
16
+ def download(output_dir = nil)
17
+ # Use ~/.idrac as the default directory
18
+ output_dir ||= File.expand_path("~/.idrac")
19
+ FileUtils.mkdir_p(output_dir) unless Dir.exist?(output_dir)
20
+
21
+ catalog_gz_path = File.join(output_dir, "Catalog.xml.gz")
22
+ catalog_path = File.join(output_dir, "Catalog.xml")
23
+
24
+ puts "Downloading Dell catalog from #{CATALOG_URL}..."
25
+
26
+ uri = URI.parse(CATALOG_URL)
27
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
28
+ request = Net::HTTP::Get.new(uri)
29
+ http.request(request) do |response|
30
+ if response.code == "200"
31
+ File.open(catalog_gz_path, 'wb') do |file|
32
+ response.read_body do |chunk|
33
+ file.write(chunk)
34
+ end
35
+ end
36
+ else
37
+ raise Error, "Failed to download catalog: #{response.code} #{response.message}"
38
+ end
39
+ end
40
+ end
41
+
42
+ puts "Extracting catalog..."
43
+ system("gunzip -f #{catalog_gz_path}")
44
+
45
+ if File.exist?(catalog_path)
46
+ puts "Catalog downloaded and extracted to #{catalog_path}"
47
+ @catalog_path = catalog_path
48
+ return catalog_path
49
+ else
50
+ raise Error, "Failed to extract catalog"
51
+ end
52
+ end
53
+
54
+ def parse
55
+ raise Error, "No catalog path specified" unless @catalog_path
56
+ raise Error, "Catalog file not found: #{@catalog_path}" unless File.exist?(@catalog_path)
57
+
58
+ File.open(@catalog_path) { |f| Nokogiri::XML(f) }
59
+ end
60
+
61
+ def find_system_models(model_name)
62
+ doc = parse
63
+ models = []
64
+
65
+ # Extract model code from full model name (e.g., "PowerEdge R640" -> "R640")
66
+ model_code = nil
67
+ if model_name.include?("PowerEdge")
68
+ model_code = model_name.split.last
69
+ else
70
+ model_code = model_name
71
+ end
72
+
73
+ puts "Searching for model: #{model_name} (code: #{model_code})"
74
+
75
+ # Build a mapping of model names to system IDs
76
+ model_to_system_id = {}
77
+
78
+ doc.xpath('//SupportedSystems/Brand/Model').each do |model|
79
+ system_id = model['systemID'] || model['id']
80
+ name = model.at_xpath('Display')&.text
81
+ code = model.at_xpath('Code')&.text
82
+
83
+ if name && system_id
84
+ model_to_system_id[name] = {
85
+ name: name,
86
+ code: code,
87
+ id: system_id
88
+ }
89
+
90
+ # Also map just the model number (R640, etc.)
91
+ if name =~ /[RT]\d+/
92
+ model_short = name.match(/([RT]\d+\w*)/)[1]
93
+ model_to_system_id[model_short] = {
94
+ name: name,
95
+ code: code,
96
+ id: system_id
97
+ }
98
+ end
99
+ end
100
+ end
101
+
102
+ # Try exact match first
103
+ if model_to_system_id[model_name]
104
+ models << model_to_system_id[model_name]
105
+ end
106
+
107
+ # Try model code match
108
+ if model_to_system_id[model_code]
109
+ models << model_to_system_id[model_code]
110
+ end
111
+
112
+ # If we still don't have a match, try a more flexible approach
113
+ if models.empty?
114
+ model_to_system_id.each do |name, model_info|
115
+ if name.include?(model_code) || model_code.include?(name)
116
+ models << model_info
117
+ end
118
+ end
119
+ end
120
+
121
+ # If still no match, try matching by systemID directly
122
+ if models.empty?
123
+ doc.xpath('//SupportedSystems/Brand/Model').each do |model|
124
+ system_id = model['systemID'] || model['id']
125
+ name = model.at_xpath('Display')&.text
126
+ code = model.at_xpath('Code')&.text
127
+
128
+ if code && code.downcase == model_code.downcase
129
+ models << {
130
+ name: name,
131
+ code: code,
132
+ id: system_id
133
+ }
134
+ end
135
+ end
136
+ end
137
+
138
+ models.uniq { |m| m[:id] }
139
+ end
140
+
141
+ def find_updates_for_system(system_id)
142
+ doc = parse
143
+ updates = []
144
+
145
+ # Find all SoftwareComponents
146
+ doc.xpath("//SoftwareComponent").each do |component|
147
+ # Check if this component supports our system ID
148
+ supported_system_ids = component.xpath(".//SupportedSystems/Brand/Model/@systemID | .//SupportedSystems/Brand/Model/@id").map(&:value)
149
+
150
+ next unless supported_system_ids.include?(system_id)
151
+
152
+ # Get component details
153
+ name_node = component.xpath("./Name/Display[@lang='en']").first
154
+ name = name_node ? name_node.text.strip : ""
155
+
156
+ component_type_node = component.xpath("./ComponentType/Display[@lang='en']").first
157
+ component_type = component_type_node ? component_type_node.text.strip : ""
158
+
159
+ path = component['path'] || ""
160
+ category_node = component.xpath("./Category/Display[@lang='en']").first
161
+ category = category_node ? category_node.text.strip : ""
162
+
163
+ version = component['dellVersion'] || component['vendorVersion'] || ""
164
+
165
+ # Skip if missing essential information
166
+ next if name.empty? || path.empty? || version.empty?
167
+
168
+ # Only include firmware updates
169
+ if component_type.include?("Firmware") ||
170
+ category.include?("BIOS") ||
171
+ category.include?("Firmware") ||
172
+ category.include?("iDRAC") ||
173
+ name.include?("BIOS") ||
174
+ name.include?("Firmware") ||
175
+ name.include?("iDRAC")
176
+
177
+ updates << {
178
+ name: name,
179
+ version: version,
180
+ path: path,
181
+ component_type: component_type,
182
+ category: category,
183
+ download_url: "https://downloads.dell.com/#{path}"
184
+ }
185
+ end
186
+ end
187
+
188
+ puts "Found #{updates.size} firmware updates for system ID #{system_id}"
189
+ updates
190
+ end
191
+
192
+ def extract_identifiers(name)
193
+ return [] unless name
194
+
195
+ identifiers = []
196
+
197
+ # Extract model numbers like X520, I350, etc.
198
+ model_matches = name.scan(/[IX]\d{3,4}/)
199
+ identifiers.concat(model_matches)
200
+
201
+ # Extract PERC model like H730
202
+ perc_matches = name.scan(/[HP]\d{3,4}/)
203
+ identifiers.concat(perc_matches)
204
+
205
+ # Extract other common identifiers
206
+ if name.include?("NIC") || name.include?("Ethernet") || name.include?("Network")
207
+ identifiers << "NIC"
208
+ end
209
+
210
+ if name.include?("PERC") || name.include?("RAID")
211
+ identifiers << "PERC"
212
+ # Extract PERC model like H730
213
+ perc_match = name.match(/PERC\s+([A-Z]\d{3})/)
214
+ identifiers << perc_match[1] if perc_match
215
+ end
216
+
217
+ if name.include?("BIOS")
218
+ identifiers << "BIOS"
219
+ end
220
+
221
+ if name.include?("iDRAC") || name.include?("IDRAC") || name.include?("Remote Access Controller")
222
+ identifiers << "iDRAC"
223
+ end
224
+
225
+ if name.include?("Power Supply") || name.include?("PSU")
226
+ identifiers << "PSU"
227
+ end
228
+
229
+ if name.include?("Lifecycle Controller")
230
+ identifiers << "LC"
231
+ end
232
+
233
+ if name.include?("CPLD")
234
+ identifiers << "CPLD"
235
+ end
236
+
237
+ identifiers
238
+ end
239
+
240
+ def match_component(firmware_name, catalog_name)
241
+ # Normalize names for comparison
242
+ catalog_name_lower = catalog_name.downcase.strip
243
+ firmware_name_lower = firmware_name.downcase.strip
244
+
245
+ # 1. Direct substring match
246
+ return true if catalog_name_lower.include?(firmware_name_lower) || firmware_name_lower.include?(catalog_name_lower)
247
+
248
+ # 2. Special case for BIOS
249
+ return true if catalog_name_lower.include?("bios") && firmware_name_lower.include?("bios")
250
+
251
+ # 3. Check identifiers
252
+ firmware_identifiers = extract_identifiers(firmware_name)
253
+ catalog_identifiers = extract_identifiers(catalog_name)
254
+
255
+ return true if (firmware_identifiers & catalog_identifiers).any?
256
+
257
+ # 4. Special case for network adapters
258
+ if (firmware_name_lower.include?("ethernet") || firmware_name_lower.include?("network")) &&
259
+ (catalog_name_lower.include?("ethernet") || catalog_name_lower.include?("network"))
260
+ return true
261
+ end
262
+
263
+ # No match found
264
+ false
265
+ end
266
+
267
+ def compare_versions(current_version, available_version)
268
+ # If versions are identical, no update needed
269
+ return false if current_version == available_version
270
+
271
+ # If either version is N/A, no update available
272
+ return false if current_version == "N/A" || available_version == "N/A"
273
+
274
+ # Try to handle Dell's version format (e.g., A00, A01, etc.)
275
+ if available_version.match?(/^[A-Z]\d+$/)
276
+ # If current version doesn't match Dell's format, assume update is needed
277
+ return true unless current_version.match?(/^[A-Z]\d+$/)
278
+
279
+ # Compare Dell version format (A00 < A01 < A02 < ... < B00 < B01 ...)
280
+ available_letter = available_version[0]
281
+ available_number = available_version[1..-1].to_i
282
+
283
+ current_letter = current_version[0]
284
+ current_number = current_version[1..-1].to_i
285
+
286
+ return true if current_letter < available_letter
287
+ return true if current_letter == available_letter && current_number < available_number
288
+ return false
289
+ end
290
+
291
+ # For numeric versions, try to compare them
292
+ if current_version.match?(/^[\d\.]+$/) && available_version.match?(/^[\d\.]+$/)
293
+ current_parts = current_version.split('.').map(&:to_i)
294
+ available_parts = available_version.split('.').map(&:to_i)
295
+
296
+ # Compare each part of the version
297
+ max_length = [current_parts.length, available_parts.length].max
298
+ max_length.times do |i|
299
+ current_part = current_parts[i] || 0
300
+ available_part = available_parts[i] || 0
301
+
302
+ return true if current_part < available_part
303
+ return false if current_part > available_part
304
+ end
305
+
306
+ # If we get here, versions are equal
307
+ return false
308
+ end
309
+
310
+ # If we can't determine, assume update is needed
311
+ true
312
+ end
313
+ end
314
+ end
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.1.26"
4
+ VERSION = "0.1.29"
5
5
  end
data/lib/idrac.rb CHANGED
@@ -10,21 +10,16 @@ require 'uri'
10
10
  require 'debug' if ENV['RUBY_ENV'] == 'development'
11
11
 
12
12
  require_relative "idrac/version"
13
+ require_relative "idrac/error"
13
14
  require_relative "idrac/client"
14
15
  require_relative "idrac/screenshot"
15
16
  require_relative "idrac/firmware"
17
+ require_relative "idrac/firmware_catalog"
16
18
 
17
19
  module IDRAC
18
20
  class Error < StandardError; end
19
21
 
20
- def self.new(host:, username:, password:, port: 443, use_ssl: true, verify_ssl: true)
21
- Client.new(
22
- host: host,
23
- username: username,
24
- password: password,
25
- port: port,
26
- use_ssl: use_ssl,
27
- verify_ssl: verify_ssl
28
- )
22
+ def self.new(options = {})
23
+ Client.new(options)
29
24
  end
30
25
  end
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative 'lib/idrac'
4
+
5
+ # Create a client
6
+ client = IDRAC::Client.new(
7
+ host: '127.0.0.1',
8
+ username: 'root',
9
+ password: 'calvin',
10
+ verify_ssl: false
11
+ )
12
+
13
+ begin
14
+ # Login to iDRAC
15
+ client.login
16
+ puts "Logged in successfully"
17
+
18
+ # Create a firmware instance
19
+ firmware = IDRAC::Firmware.new(client)
20
+
21
+ # Get system inventory
22
+ puts "Getting system inventory..."
23
+ inventory = firmware.get_system_inventory
24
+
25
+ puts "System Information:"
26
+ puts " Model: #{inventory[:system][:model]}"
27
+ puts " Manufacturer: #{inventory[:system][:manufacturer]}"
28
+ puts " Service Tag: #{inventory[:system][:service_tag]}"
29
+ puts " BIOS Version: #{inventory[:system][:bios_version]}"
30
+
31
+ puts "\nInstalled Firmware:"
32
+ inventory[:firmware].each do |fw|
33
+ puts " #{fw[:name]}: #{fw[:version]} (#{fw[:updateable] ? 'Updateable' : 'Not Updateable'})"
34
+ end
35
+
36
+ # Check for updates
37
+ catalog_path = File.expand_path("~/.idrac/Catalog.xml")
38
+ if File.exist?(catalog_path)
39
+ puts "\nChecking for updates using catalog: #{catalog_path}"
40
+ updates = firmware.check_updates(catalog_path)
41
+
42
+ if updates.empty?
43
+ puts "No updates available."
44
+ else
45
+ puts "\nAvailable Updates:"
46
+ updates.each_with_index do |update, index|
47
+ puts "#{index + 1}. #{update[:name]}: #{update[:current_version]} -> #{update[:available_version]}"
48
+ end
49
+
50
+ # For testing, select the first update
51
+ if updates.any?
52
+ selected_update = updates.first
53
+ puts "\nSelected update: #{selected_update[:name]}: #{selected_update[:current_version]} -> #{selected_update[:available_version]}"
54
+
55
+ # Download and install the update
56
+ puts "Starting interactive update for selected component..."
57
+ firmware.interactive_update(catalog_path, [selected_update])
58
+ end
59
+ end
60
+ else
61
+ puts "\nCatalog file not found at #{catalog_path}. Please download the catalog first."
62
+ end
63
+
64
+ rescue IDRAC::Error => e
65
+ puts "Error: #{e.message}"
66
+ ensure
67
+ # Logout
68
+ client.logout if client.instance_variable_get(:@session_id)
69
+ puts "Logged out"
70
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: idrac
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.26
4
+ version: 0.1.29
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jonathan Siegel
@@ -209,10 +209,13 @@ files:
209
209
  - idrac.gemspec
210
210
  - lib/idrac.rb
211
211
  - lib/idrac/client.rb
212
+ - lib/idrac/error.rb
212
213
  - lib/idrac/firmware.rb
214
+ - lib/idrac/firmware_catalog.rb
213
215
  - lib/idrac/screenshot.rb
214
216
  - lib/idrac/version.rb
215
217
  - sig/idrac.rbs
218
+ - test_firmware_update.rb
216
219
  homepage: http://github.com
217
220
  licenses:
218
221
  - MIT