idrac 0.10.2 → 0.10.4

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: 5e3c0e2f73e3449a852e4e46ca5838705d9ac5eeb780c7fa2023329747406a94
4
- data.tar.gz: 7430d5ee6cf11aee77dd5f01990a7a1fca680cbfeb40fbad9e65a7a933ed7619
3
+ metadata.gz: 17491587256894c2ff168665dca777903a2d6b7d7582f745c4c3e81f697889cd
4
+ data.tar.gz: 432ecbfd6df649a69644254dc942e253a8d917f6ce2cd07c30f7f328c3ffee75
5
5
  SHA512:
6
- metadata.gz: 0cbb04e015b2cb0d4c5181355e34c5768250929e99b101b2a23d946b61b6b24f9b4ca8b6e803a7660ffe5f0d388d0f89c8a8b5ef9dda0cb262fcbd580260cfcc
7
- data.tar.gz: e5284b739c221f9b96697dfdccb14d148e9a07ff1cc86131fc4975f608526583a13f540ba243b9167455de6325f1ced21bb6416230512f2d71a52f2765d6888b
6
+ metadata.gz: 28cc04b7ec2f246ed29d95ba50481f52dff7893e69fd94dc6f82db81e0d7b988e6d0e21589ae09a75e99c51621f2a6650a1aa0ada2c74c8aa4ff6f2726cadf98
7
+ data.tar.gz: 6981e38fe25c803372a0c09aa3616db6ce7d0670415fbec9f73e143c25e44fff25e24ea650dfe5ebfcc821b14c6d17dd778664e43d11b5fbfd8c6458cacae374
data/README.md CHANGED
@@ -167,9 +167,14 @@ client.clear_jobs!
167
167
  # Force clear job queue (use with caution)
168
168
  client.force_clear_jobs!
169
169
 
170
- # Wait for a specific job to complete
170
+ # Wait for a specific job to complete (raises IDRAC::Error if the job failed)
171
171
  job_data = client.wait_for_job("JID_12345678")
172
172
 
173
+ # Wait for a job and get its outcome back instead of an exception
174
+ result = client.wait_for_job_completion("JID_12345678")
175
+ # => { status: :success, job_id: "JID_12345678", job_state: "Completed",
176
+ # message: "...", messages: ["..."], job: { ... } }
177
+
173
178
  # Lifecycle Controller operations
174
179
  # Check if Lifecycle Controller is enabled
175
180
  status = client.get_idrac_lifecycle_status
@@ -291,6 +296,30 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
291
296
 
292
297
  ## Changelog
293
298
 
299
+ ### Version 0.10.3
300
+ - **Fixed: SCP imports reported as failures when they had succeeded**: `set_system_configuration_profile` waited on the *task* that submitted the import. iDRAC answers the import with a TaskService URI whose id is the id of the job it queued, and that task sits at `TaskState "Pending"` / `"New job."` while the job does the real work. The transient task state was returned as `{status: :failed, task_state: "Pending", messages: ["New job."]}`, so callers aborted on imports that went on to complete.
301
+ - The import now waits for the **job** to reach a terminal state and returns the **job's** outcome
302
+ - Return value: `{ status: :success | :failed | :timeout, job_id:, job_state:, message:, messages:, job: }`, plus `error:` for anything other than `:success` (`:status` is unchanged; `:job_id` is new, so callers no longer have to diff the job list to find the job)
303
+ - `Failed`, `CompletedWithErrors`, and `RebootFailed` surface the job's own `Message`
304
+ - A location header that points straight at a job still uses the job path
305
+ - New `timeout:` option (default 600s)
306
+ - **New `wait_for_job_completion(job_id, timeout:, interval:)`**: waits for a job and returns its outcome as a hash rather than raising, keeps polling while the host is briefly unreachable (an SCP import can bounce it), and always reports the job id
307
+ - **Fixed `wait_for_task` treating a queued task as failed**: it broke out of its poll loop on any state other than `Running`, so `New`/`Pending`/`Starting` were reported as failures. It now waits through every non-terminal state, accepts a full location header, and takes a `timeout:` (default 600s)
308
+ - **Removed stray `debugger` calls** from `wait_for_task` and `import_system_configuration`
309
+
310
+ ### Version 0.10.2
311
+ - **Fixed: firmware updates matched the wrong Dell Update Package**: `check_updates` paired an installed component with a catalog DUP by display name. Names are ambiguous — every NIC package reads "Ethernet" — so it selected packages that do not flash the installed device. iDRAC rejected those on apply (RED097 / "not compatible") and the version column compared unrelated components.
312
+ - Matching is now done on the Dell **componentID**, the key that actually identifies the device
313
+ - `find_updates_for_system` records `component_ids:` for each DUP, read from `SupportedDevices/Device/@componentID`
314
+ - `get_system_inventory` records `component_id:` for each installed component, from the Dell OEM block when present and otherwise parsed out of the inventory `Id` (`Installed-<componentID>-<version>`), which is all iDRAC8 exposes
315
+ - New `FirmwareCatalog#updates_for_component(catalog_updates, fw)` does the matching, and falls back to the old name heuristic only when the inventory reports no componentID (or reports `"0"`)
316
+ - New `FirmwareCatalog#version_key(version)` gives a sortable key, so when the catalog lists several revisions for one component the newest is chosen
317
+ - **Fixed: every component reported as out of date**: the catalog version came from `dellVersion`, which is the package revision label (`A17`), not a version. Comparing that against an installed numeric version (`25.5.9.0001`) flagged everything. `vendorVersion` is now preferred.
318
+
319
+ ### Versions 0.10.0-0.10.1
320
+ - Use the Dell OEM `Install` action on iDRAC8 instead of `SimpleUpdate`
321
+ - Fixed a false-positive virtual-media "inserted" state on iDRAC8 (benign VRM0009 500 on eject is now a no-op)
322
+
294
323
  ### Version 0.9.1
295
324
  - **Code Simplification**: Removed redundant `handle_response` calls throughout the codebase
296
325
  - `authenticated_request` now automatically handles error responses (status >= 400)
data/bin/idrac CHANGED
@@ -1663,12 +1663,19 @@ module IDRAC
1663
1663
  # TSR (Technical Support Report) commands
1664
1664
  desc "tsr_collect", "Generate and download TSR/SupportAssist collection"
1665
1665
  method_option :output, type: :string, desc: "Output filename"
1666
+ method_option :timeout, type: :numeric, default: 600, desc: "Seconds to wait for collection to complete"
1667
+ method_option :data_types, type: :string, desc: "Comma-separated log types to collect (0=HWData, 1=OSAppData, 2=TTYLogs, 3=DebugLogs; default: HWData,OSAppData)"
1666
1668
  def tsr_collect
1667
1669
  with_idrac_client do |client|
1668
1670
  puts "Generating and downloading TSR logs...".yellow
1669
1671
  puts "This may take several minutes...".yellow
1670
-
1671
- result = client.generate_and_download_tsr(output_file: options[:output])
1672
+
1673
+ data_selector = options[:data_types]&.split(',')
1674
+ result = client.generate_and_download_tsr(
1675
+ output_file: options[:output],
1676
+ data_selector_values: data_selector,
1677
+ wait_timeout: options[:timeout]
1678
+ )
1672
1679
 
1673
1680
  if result
1674
1681
  puts "TSR logs saved to: #{result}".green
data/idrac.gemspec CHANGED
@@ -34,7 +34,7 @@ Gem::Specification.new do |spec|
34
34
  spec.require_paths = ["lib"]
35
35
 
36
36
  # Dependencies - Using semantic versioning recommendations
37
- spec.add_dependency "httparty", "~> 0.21", ">= 0.21.0"
37
+ spec.add_dependency "httparty", "~> 0.24", ">= 0.24.0"
38
38
  spec.add_dependency "nokogiri", "~> 1.15", ">= 1.15.0"
39
39
  spec.add_dependency "faraday", "~> 2.7", ">= 2.7.0"
40
40
  spec.add_dependency "faraday-multipart", "~> 1.0", ">= 1.0.0"
data/lib/idrac/boot.rb CHANGED
@@ -323,22 +323,7 @@ module IDRAC
323
323
  raise Error, "Failed to get BIOS information. Status code: #{response.status}"
324
324
  end
325
325
  end
326
- =begin
327
- # Servers can boot in BIOS mode or in UEFI (modern, extensible BIOS replacement) mode.
328
- # We use UEFI mode.
329
- # self.get(path: "Systems/System.Embedded.1/Bios/Settings?$select=BootMode")
330
- res = self.get(path: "Systems/System.Embedded.1/Bios")
331
- if res["body"]["Attributes"]["BootMode"] == "Uefi"
332
- return { status: :success }
333
- else
334
- res = self.set_system_configuration_profile(scp_boot_mode_uefi, reboot: true)
335
- # Then must power cycle the server
336
- self.power_on!(wait: true)
337
- self.power_off!(wait: true)
338
- return res
339
- end
340
326
 
341
- =end
342
327
  def scp_boot_mode_uefi(idrac_license_version: 9)
343
328
  opts = { "BootMode" => 'Uefi' }
344
329
  # If we're iDRAC 9, we need enable a placeholder, otherwise we can't order the
@@ -675,9 +660,8 @@ module IDRAC
675
660
  body: params.to_json
676
661
  )
677
662
 
678
- task = wait_for_task(response.headers["location"])
679
- debugger
680
- return task
663
+ # Same operation as set_system_configuration_profile: wait on the import JOB.
664
+ return wait_for_scp_import(response.headers["location"])
681
665
  end
682
666
  end
683
667
  end
data/lib/idrac/client.rb CHANGED
@@ -392,10 +392,18 @@ module IDRAC
392
392
  raise e
393
393
  end
394
394
  end
395
- end # Wait for a task to complete
395
+ end
396
+
397
+ # Task states that mean the task has not produced a result yet. A task that has only
398
+ # been queued reports "Pending"/"New" ("New job."), which is not an outcome -- so we
399
+ # keep waiting instead of reporting that transient state as a failure.
400
+ TASK_INCOMPLETE_STATES = %w[New Starting Pending Running Stopping Cancelling].freeze
396
401
 
397
- def wait_for_task(task_id)
402
+ # Wait for a task to complete
403
+ def wait_for_task(task_id, timeout: 600)
404
+ task_id = task_id.to_s.split("/").last # tolerate being handed a full location header
398
405
  task = nil
406
+ deadline = Time.now + timeout
399
407
 
400
408
  begin
401
409
  loop do
@@ -406,9 +414,7 @@ module IDRAC
406
414
  when 200..299
407
415
  task = JSON.parse(task_response.body)
408
416
 
409
- if task["TaskState"] != "Running"
410
- break
411
- end
417
+ break unless TASK_INCOMPLETE_STATES.include?(task["TaskState"])
412
418
 
413
419
  # Extract percentage complete if available
414
420
  percent_complete = nil
@@ -418,6 +424,12 @@ module IDRAC
418
424
  end
419
425
 
420
426
  debug "Waiting for task to complete...: #{task["TaskState"]} #{task["TaskStatus"]}", 1
427
+
428
+ if Time.now >= deadline
429
+ return { status: :timeout, task_state: task["TaskState"], task_status: task["TaskStatus"],
430
+ error: "Timed out after #{timeout}s waiting for task #{task_id} (state: #{task["TaskState"]})" }
431
+ end
432
+
421
433
  sleep 5
422
434
  else
423
435
  return {
@@ -451,7 +463,7 @@ module IDRAC
451
463
  }
452
464
  end
453
465
  rescue => e
454
- debugger
466
+ debug "Exception monitoring task #{task_id}: #{e.message}", 1, :red
455
467
  return { status: :error, error: "Exception monitoring task: #{e.message}" }
456
468
  end
457
469
  end
data/lib/idrac/jobs.rb CHANGED
@@ -185,7 +185,55 @@ module IDRAC
185
185
 
186
186
  raise Error, "Timeout waiting for job to complete"
187
187
  end
188
-
188
+
189
+ # Job states that mean the job is finished, one way or another.
190
+ JOB_TERMINAL_STATES = %w[Completed CompletedWithErrors Failed RebootFailed].freeze
191
+
192
+ # Wait for an iDRAC job to reach a terminal state and report on the JOB itself.
193
+ #
194
+ # Unlike wait_for_job this does not raise for a job that simply failed -- it returns
195
+ # the outcome so the caller can log or re-check it. The hash always carries :job_id.
196
+ #
197
+ # { status: :success | :failed | :timeout, job_id:, job_state:,
198
+ # message:, messages: [message], job: <raw job data>,
199
+ # error: <the job's own message, on anything but :success> }
200
+ def wait_for_job_completion(job_id, timeout: 600, interval: 10)
201
+ job_id = job_id.to_s.split("/").last
202
+ deadline = Time.now + timeout
203
+ state = nil
204
+ last_error = nil
205
+
206
+ while Time.now < deadline
207
+ begin
208
+ response = authenticated_request(:get, "/redfish/v1/Managers/iDRAC.Embedded.1/Jobs/#{job_id}")
209
+ job = response.status == 200 ? JSON.parse(response.body) : nil
210
+ state = job ? job["JobState"] : nil
211
+
212
+ if JOB_TERMINAL_STATES.include?(state)
213
+ message = job["Message"] || Array(job["Messages"]).map { |m| m["Message"] }.compact.first
214
+ success = state == "Completed"
215
+ debug "Job #{job_id} #{state}: #{message}", 1, success ? :green : :red
216
+ result = { status: success ? :success : :failed, job_id: job_id, job_state: state,
217
+ message: message, messages: [message].compact, job: job }
218
+ result[:error] = message || "Job #{job_id} finished with state #{state}" unless success
219
+ return result
220
+ end
221
+
222
+ last_error = "HTTP #{response.status}" unless job
223
+ debug "Job #{job_id} state: #{state || last_error}. Waiting...", 1, :yellow
224
+ rescue StandardError => e
225
+ # An SCP import can bounce the host, so a job can be briefly unreadable. Keep polling.
226
+ last_error = e.message
227
+ debug "Job #{job_id} not readable (#{e.message}). Waiting...", 1, :yellow
228
+ end
229
+
230
+ sleep interval
231
+ end
232
+
233
+ { status: :timeout, job_id: job_id, job_state: state,
234
+ error: "Timed out after #{timeout}s waiting for job #{job_id} (last state: #{state || last_error || 'unknown'})" }
235
+ end
236
+
189
237
  # Get system tasks
190
238
  def tasks
191
239
  response = authenticated_request(:get, '/redfish/v1/TaskService/Tasks')
data/lib/idrac/power.rb CHANGED
@@ -58,7 +58,15 @@ module IDRAC
58
58
  return true
59
59
  end
60
60
 
61
+ # Reset types that restart the host rather than leave it down. A restart never settles on
62
+ # "Off", so it must never be waited on — or escalated to — as if it were a shutdown.
63
+ RESTART_TYPES = %w[ForceRestart GracefulRestart PowerCycle].freeze
64
+
61
65
  def power_off(wait: true, kind: "ForceOff")
66
+ # A restart is not a shutdown. Sent down the power-off path it fails the wait for "Off" and
67
+ # escalates to ForceOff, which leaves the host powered down. Route it to the restart instead.
68
+ return reboot(kind: kind, wait: wait) if RESTART_TYPES.include?(kind)
69
+
62
70
  ensure_authenticated!
63
71
 
64
72
  puts "Powering off server...".light_cyan
@@ -93,37 +101,39 @@ module IDRAC
93
101
  return true
94
102
  end
95
103
 
96
- def reboot
104
+ def reboot(kind: "ForceRestart", wait: false)
97
105
  ensure_authenticated!
98
106
 
99
- puts "Rebooting server...".light_cyan
107
+ puts "Rebooting server (#{kind})...".light_cyan
100
108
 
101
109
  # Check current power state first
102
110
  current_state = get_power_state rescue "Unknown"
103
111
  if current_state == "Off"
104
112
  puts "Server is currently off, powering on instead of rebooting".yellow
105
- return power_on
113
+ return power_on(wait: wait)
106
114
  end
107
115
 
108
- # Send reboot command (Reset with ResetType=ForceRestart)
109
116
  path = "/redfish/v1/Systems/System.Embedded.1/Actions/ComputerSystem.Reset"
110
- payload = { "ResetType" => "ForceRestart" }
111
-
112
- response = authenticated_request(:post, path, body: payload.to_json)
117
+ response = authenticated_request(:post, path, body: { "ResetType" => kind }.to_json)
113
118
 
114
119
  if response.status >= 200 && response.status < 300
115
120
  puts "Server reboot command sent successfully".green
116
- return true
117
- elsif response.status == 409
121
+ elsif response.status == 409 && kind != "GracefulRestart"
118
122
  error_data = JSON.parse(response.body) rescue nil
119
123
  puts "Received conflict (409) error from iDRAC: #{error_data.inspect}"
120
124
  # Try gracefulRestart as an alternative
121
125
  puts "Trying GracefulRestart instead...".yellow
122
- payload = { "ResetType" => "GracefulRestart" }
123
- authenticated_request(:post, path, body: payload.to_json)
126
+ response = authenticated_request(:post, path, body: { "ResetType" => "GracefulRestart" }.to_json)
127
+ raise Error, "Failed to reboot server. Status code: #{response.status}" unless response.status.between?(200, 299)
124
128
  else
125
129
  raise Error, "Failed to reboot server. Status code: #{response.status}"
126
130
  end
131
+
132
+ # "On" is the only terminal state a restart reaches. Never wait for "Off" here: the host may
133
+ # never report it, and treating that as failure is what turned a reboot into a shutdown.
134
+ wait_for_power_state(target_state: "On", tries: 12) if wait
135
+
136
+ return true
127
137
  end
128
138
 
129
139
  def get_power_state
@@ -84,8 +84,13 @@ module IDRAC
84
84
  end
85
85
  end
86
86
 
87
- # Apply a system configuration profile to the iDRAC
88
- def set_system_configuration_profile(scp, target: "ALL", reboot: false, retry_count: 0)
87
+ # Apply a system configuration profile to the iDRAC.
88
+ #
89
+ # Waits for the import JOB to finish and returns the JOB's outcome:
90
+ #
91
+ # { status: :success | :failed | :timeout, job_id:, job_state:, message:,
92
+ # messages: [message], job: <raw job data>, error: <message unless :success> }
93
+ def set_system_configuration_profile(scp, target: "ALL", reboot: false, timeout: 600, retry_count: 0)
89
94
  # Ensure scp has the proper structure with SystemConfiguration wrapper
90
95
  scp_to_apply = if scp.is_a?(Hash) && scp["SystemConfiguration"]
91
96
  scp
@@ -135,7 +140,27 @@ module IDRAC
135
140
  return { status: :failed, error: error_message }
136
141
  end
137
142
 
138
- return handle_location(response.headers["location"])
143
+ return wait_for_scp_import(response.headers["location"], timeout: timeout)
144
+ end
145
+
146
+ # Wait for an SCP import submitted at +location+ and report the JOB's outcome.
147
+ #
148
+ # iDRAC answers the import with a TaskService URI whose id is the id of the job it
149
+ # queued (/redfish/v1/TaskService/Tasks/JID_...). That task is only the submission:
150
+ # it sits at TaskState "Pending" / "New job." while the job does the real work, so
151
+ # waiting on the task reports a queued job as a failure. The job is the operation,
152
+ # so the job is what we wait on -- and the job id goes back to the caller.
153
+ def wait_for_scp_import(location, timeout: 600)
154
+ return { status: :failed, error: "SCP import returned no location header" } if location.nil? || location.to_s.empty?
155
+
156
+ id = location.to_s.split("/").last
157
+ # Dell job ids (JID_/RID_) name the job whether the header points at the job itself
158
+ # or at the task that queued it. Anything else is a real task: handle it as before.
159
+ if location.to_s.include?("/Jobs/") || id.start_with?("JID_", "RID_")
160
+ wait_for_job_completion(id, timeout: timeout)
161
+ else
162
+ handle_location(location)
163
+ end
139
164
  end
140
165
 
141
166
  # This puts the SCP into a format that can be used by reasonable Ruby code.
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.10.2"
4
+ VERSION = "0.10.4"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: idrac
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.2
4
+ version: 0.10.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jonathan Siegel
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-03 00:00:00.000000000 Z
11
+ date: 2026-08-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: httparty
@@ -16,20 +16,20 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '0.21'
19
+ version: '0.24'
20
20
  - - ">="
21
21
  - !ruby/object:Gem::Version
22
- version: 0.21.0
22
+ version: 0.24.0
23
23
  type: :runtime
24
24
  prerelease: false
25
25
  version_requirements: !ruby/object:Gem::Requirement
26
26
  requirements:
27
27
  - - "~>"
28
28
  - !ruby/object:Gem::Version
29
- version: '0.21'
29
+ version: '0.24'
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: 0.21.0
32
+ version: 0.24.0
33
33
  - !ruby/object:Gem::Dependency
34
34
  name: nokogiri
35
35
  requirement: !ruby/object:Gem::Requirement