idrac 0.10.6 → 0.11.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: fae473e8260d010235ebf86c7ed40b33dae6b7a1c14c2576bce55fecc319c7be
4
- data.tar.gz: 8c115aeb8c6399d2d3d167204b160ef691b64ec41570b371912df697b23d9789
3
+ metadata.gz: aae19c4ce800e57cb1b15ca37be3d48d1c22f52d2eb455a877fe517f21bd65f4
4
+ data.tar.gz: 1f772c3a6c316578e572e8b797695e19096e1a04e424f38633f6dfad56b6a12c
5
5
  SHA512:
6
- metadata.gz: 278b81e8955021924c44c496b0df20c34f59e3630f9df3decda96ee519cadc0445901cddbf3c78c174313c36eef558780ce444fb09f3d870039adc23e7da5056
7
- data.tar.gz: c66f192778e2e32cf724b3b98ef45c2311a79ee8f0144e0f95a3106ff1d2d87f67f13c30eac4a47c2055e302bb5b2dc373cf37cfc760ff2566a8f949531db061
6
+ metadata.gz: 51c2314dee2b317013816c1e87aacd0d2f5808bb63921826da788d50248e7b84fb871b14abcbc92f92b56ae7cea18d69b5ed32727939549351ee8026510356a1
7
+ data.tar.gz: e2fe2aad75892982b41819ac1dafd13a7317c85e7d427ee6379b21a8b2daa1e2563c87b1ac97d1910ff9f5900058562a727b06b3e104e9a3183cb82850843db0
data/lib/idrac/boot.rb CHANGED
@@ -140,7 +140,8 @@ module IDRAC
140
140
  end
141
141
 
142
142
  # Set boot override for next boot
143
- def set_boot_override(target, enabled: "Once", mode: nil)
143
+ def set_boot_override(target, persistence: nil, mode: nil)
144
+ persistence = "Once" unless persistence
144
145
  # Validate target against allowed values
145
146
  boot_data = boot
146
147
  valid_targets = boot_data["allowed_override_targets"]
@@ -150,11 +151,11 @@ module IDRAC
150
151
  raise Error, "Invalid boot target: #{target}"
151
152
  end
152
153
 
153
- debug "Setting boot override to #{target} (#{enabled})...", 1, :yellow
154
+ debug "Setting boot override to #{target} (#{persistence})...", 1, :yellow
154
155
 
155
156
  body = {
156
157
  "Boot" => {
157
- "BootSourceOverrideEnabled" => enabled, # Disabled/Once/Continuous
158
+ "BootSourceOverrideEnabled" => persistence, # Disabled/Once/Continuous
158
159
  "BootSourceOverrideTarget" => target # None/Pxe/Hdd/Cd/etc
159
160
  }
160
161
  }
@@ -225,24 +226,24 @@ module IDRAC
225
226
  end
226
227
 
227
228
  # Convenience methods for common boot targets
228
- def boot_to_pxe(enabled: "Once", mode: nil)
229
- set_boot_override("Pxe", enabled: enabled, mode: mode)
229
+ def boot_to_pxe(persistence: nil, mode: nil)
230
+ set_boot_override("Pxe", persistence: persistence, mode: mode)
230
231
  end
231
232
 
232
- def boot_to_disk(enabled: "Once", mode: nil)
233
- set_boot_override("Hdd", enabled: enabled, mode: mode)
233
+ def boot_to_disk(persistence: nil, mode: nil)
234
+ set_boot_override("Hdd", persistence: persistence, mode: mode)
234
235
  end
235
236
 
236
- def boot_to_cd(enabled: "Once", mode: nil)
237
- set_boot_override("Cd", enabled: enabled, mode: mode)
237
+ def boot_to_cd(persistence: nil, mode: nil)
238
+ set_boot_override("Cd", persistence: persistence, mode: mode)
238
239
  end
239
240
 
240
- def boot_to_usb(enabled: "Once", mode: nil)
241
- set_boot_override("Usb", enabled: enabled, mode: mode)
241
+ def boot_to_usb(persistence: nil, mode: nil)
242
+ set_boot_override("Usb", persistence: persistence, mode: mode)
242
243
  end
243
244
 
244
- def boot_to_bios_setup(enabled: "Once", mode: nil)
245
- set_boot_override("BiosSetup", enabled: enabled, mode: mode)
245
+ def boot_to_bios_setup(persistence: nil, mode: nil)
246
+ set_boot_override("BiosSetup", persistence: persistence, mode: mode)
246
247
  end
247
248
 
248
249
  private
@@ -663,5 +664,114 @@ module IDRAC
663
664
  # Same operation as set_system_configuration_profile: wait on the import JOB.
664
665
  return wait_for_scp_import(response.headers["location"])
665
666
  end
667
+
668
+ ########################################################
669
+ # Boot mechanics moved out of the app's raw Redfish (radfish #40). These used to live in the
670
+ # app's Infra::OsInstall as hand-rolled authenticated_request calls; the iDRAC BootSources /
671
+ # one-time-boot mechanics belong here, the adapter and the radfish facade just expose them.
672
+ ########################################################
673
+
674
+ # Stale UEFI boot placeholders a prior OS can leave behind. Dell names them
675
+ # "Unknown.Unknown.<n>-<n>"; left ENABLED they make UEFI loop over dead entries and can keep a
676
+ # node from ever reaching an install (this trapped n008: PXE plus five "Unknown.Unknown" ghosts,
677
+ # no disk entry). The default match targets exactly them.
678
+ STALE_UEFI_BOOT_ENTRY = /\AUnknown\.Unknown\./
679
+
680
+ # The still-ENABLED UEFI boot entries whose Name matches +match+ (default: the stale
681
+ # "Unknown.Unknown.*" placeholders). Returns the raw BootSources entry hashes so callers can read
682
+ # Name/Id/Index; [] when there are none. Read-only.
683
+ def stale_uefi_boot_entries(match: STALE_UEFI_BOOT_ENTRY)
684
+ res = authenticated_request(:get, "/redfish/v1/Systems/System.Embedded.1/BootSources")
685
+ body = res.body.is_a?(String) ? JSON.parse(res.body) : res.body
686
+ seq = body.dig("Attributes", "UefiBootSeq") || []
687
+ seq.select { |e| e["Name"].to_s.match?(match) && e["Enabled"] != false }
688
+ end
689
+
690
+ # Disable the UEFI boot entries whose Name matches +match+ (default: the stale
691
+ # "Unknown.Unknown.*" placeholders) and return the NAMES disabled ([] when there was nothing to
692
+ # do). Drains any pending Lifecycle Controller config job FIRST so scheduling ours never trips
693
+ # LC068, then PATCHes BootSources/Settings and POSTs the BIOS config job that applies the change.
694
+ # The disable is applied by a reboot -- the LifecycleController runs the pending job during POST
695
+ # -- so by default this only SCHEDULES it and the caller owns power. Pass wait: true to poll the
696
+ # scheduled BIOS config job to a terminal state here (via wait_config_job) before returning.
697
+ def disable_boot_entries(match: STALE_UEFI_BOOT_ENTRY, wait: false, timeout: 900)
698
+ res = authenticated_request(:get, "/redfish/v1/Systems/System.Embedded.1/BootSources")
699
+ body = res.body.is_a?(String) ? JSON.parse(res.body) : res.body
700
+ seq = body.dig("Attributes", "UefiBootSeq") || []
701
+ stale = seq.select { |e| e["Name"].to_s.match?(match) && e["Enabled"] != false }
702
+ return [] if stale.empty?
703
+
704
+ # A stale pending job would make the config-job POST below hard-fail with LC068; drain first.
705
+ drain_pending_config_jobs!
706
+
707
+ newseq = seq.each_with_index.map do |e, i|
708
+ off = e["Name"].to_s.match?(match)
709
+ { "Enabled" => (off ? false : e["Enabled"]), "Id" => e["Id"], "Index" => i, "Name" => e["Name"] }
710
+ end
711
+ patch = authenticated_request(:patch, "/redfish/v1/Systems/System.Embedded.1/BootSources/Settings",
712
+ body: JSON.generate("Attributes" => { "UefiBootSeq" => newseq }))
713
+ raise Error, "Disabling stale boot sources failed (HTTP #{patch.status}): #{patch.body}" unless patch.status.between?(200, 299)
714
+
715
+ job = authenticated_request(:post, "/redfish/v1/Managers/iDRAC.Embedded.1/Jobs",
716
+ body: JSON.generate("TargetSettingsURI" => "/redfish/v1/Systems/System.Embedded.1/BootSources/Settings"))
717
+ raise Error, "BIOS config job for boot sources failed (HTTP #{job.status}): #{job.body}" unless job.status.between?(200, 299)
718
+
719
+ # Default: the caller's reboot applies the change (LC runs the pending job during POST). With
720
+ # wait: true, poll the scheduled config job to a terminal state -- reuse Jobs#wait_config_job.
721
+ if wait
722
+ headers = job.respond_to?(:headers) ? (job.headers || {}) : {}
723
+ jid = (headers["location"] || headers["Location"]).to_s[/(JID_\w+)/, 1] || job.body.to_s[/(JID_\w+)/, 1]
724
+ wait_config_job(jid, timeout: timeout) if jid
725
+ end
726
+
727
+ names = stale.map { |e| e["Name"] }
728
+ puts "Disabled #{names.size} stale UEFI boot #{names.size == 1 ? 'entry' : 'entries'} " \
729
+ "(#{names.join(', ')}); a BIOS config job applies it at the next boot.".green
730
+ names
731
+ end
732
+
733
+ # Redfish BootProgress.LastState mapped to the snake_case symbols radfish orders boots by
734
+ # (Radfish::Client::BOOT_PROGRESS_ORDER). Explicit so the acronym cases (PCI, OS) normalize
735
+ # correctly instead of through a naive underscore.
736
+ BOOT_PROGRESS_STATES = {
737
+ "None" => :none,
738
+ "PrimaryProcessorInitializationStarted" => :primary_processor_initialization_started,
739
+ "BusInitializationStarted" => :bus_initialization_started,
740
+ "MemoryInitializationStarted" => :memory_initialization_started,
741
+ "SecondaryProcessorInitializationStarted" => :secondary_processor_initialization_started,
742
+ "PCIResourceConfigStarted" => :pci_resource_config_started,
743
+ "SystemHardwareInitializationComplete" => :system_hardware_initialization_complete,
744
+ "SetupEntered" => :setup_entered,
745
+ "OSBootStarted" => :os_boot_started,
746
+ "OSRunning" => :os_running
747
+ }.freeze
748
+
749
+ # Normalized last BootProgress state (a snake_case Symbol from BOOT_PROGRESS_STATES), or nil when
750
+ # the BMC does not report BootProgress at all (iDRAC8 omits it). nil means "cannot observe", never
751
+ # "not running" -- callers fall back to other signals.
752
+ def boot_progress
753
+ res = authenticated_request(:get, "/redfish/v1/Systems/System.Embedded.1?$select=BootProgress")
754
+ body = res.body.is_a?(String) ? JSON.parse(res.body) : res.body
755
+ last = body.is_a?(Hash) ? body.dig("BootProgress", "LastState") : nil
756
+ return nil if last.nil? || last.to_s.empty?
757
+ BOOT_PROGRESS_STATES[last] || last.to_s.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase.to_sym
758
+ end
759
+
760
+ # Dell one-time boot to the virtual CD via an SCP import (the reliable path on this fleet). Drains
761
+ # any pending Lifecycle Controller config job first (a stale one makes the import hard-fail with
762
+ # LC068 "a configuration job is already scheduled"), then imports ServerBoot.1#BootOnce +
763
+ # FirstBootDevice=VCD-DVD. BootOnce makes the BIOS fall back to the standing order after one boot,
764
+ # so no boot-order reorder is needed here. Raises on a failed import; returns the import result hash.
765
+ def set_one_time_cd_boot(reboot: false)
766
+ drain_pending_config_jobs!
767
+ scp = { "FQDD" => "iDRAC.Embedded.1", "Attributes" => [
768
+ { "Name" => "ServerBoot.1#BootOnce", "Value" => "Enabled", "Set On Import" => "True" },
769
+ { "Name" => "ServerBoot.1#FirstBootDevice", "Value" => "VCD-DVD", "Set On Import" => "True" } ] }
770
+ res = set_system_configuration_profile(scp, target: "ALL", reboot: reboot)
771
+ unless res.is_a?(Hash) && res[:status] == :success
772
+ raise Error, "SCP one-time vCD boot failed: #{res[:job_state]} #{res[:error] || res[:message]} (job #{res[:job_id]})"
773
+ end
774
+ res
775
+ end
666
776
  end
667
- end
777
+ end
@@ -590,11 +590,10 @@ module IDRAC
590
590
  end
591
591
 
592
592
  # Construct the firmware URI from the upload response
593
- image_uri = if firmware_id
594
- "#{http_push_uri}/#{firmware_id}"
595
- else
596
- upload_response.headers['Location'] || http_push_uri
597
- end
593
+ # Prefer @odata.id from response body, then Location header, then constructed path
594
+ image_uri = upload_data&.dig('@odata.id') ||
595
+ upload_response.headers['Location'] ||
596
+ (firmware_id ? "#{http_push_uri}/#{firmware_id}" : http_push_uri)
598
597
 
599
598
  # Check if Dell OEM Install action is available (iDRAC8)
600
599
  # This is more reliable than SimpleUpdate on older iDRACs
@@ -604,9 +603,10 @@ module IDRAC
604
603
  if dell_install_target
605
604
  puts "Using Dell OEM Install action (iDRAC8)...".light_cyan
606
605
  puts "Firmware URI: #{image_uri}".light_cyan
606
+ # iDRAC8 DellUpdateService.Install expects @odata.id URIs
607
607
  install_payload = {
608
608
  "SoftwareIdentityURIs" => [image_uri],
609
- "InstallUpon" => "Now"
609
+ "InstallUpon" => "NowAndReboot"
610
610
  }
611
611
  update_response = client.authenticated_request(
612
612
  :post,
data/lib/idrac/jobs.rb CHANGED
@@ -234,6 +234,56 @@ module IDRAC
234
234
  error: "Timed out after #{timeout}s waiting for job #{job_id} (last state: #{state || last_error || 'unknown'})" }
235
235
  end
236
236
 
237
+ # Delete every Lifecycle Controller config job that is NOT Completed, and return the ids drained
238
+ # ([] when there was nothing to drain). The iDRAC serializes LC config jobs: one left
239
+ # Scheduled/Running/New/Pending makes the NEXT SCP import or config-job POST hard-fail with LC068
240
+ # ("a configuration job is already scheduled"). Repeated install/break-glass runs leave exactly
241
+ # such a stale job behind (this wedged n000), so anything that schedules its own config job drains
242
+ # first. Completed jobs are harmless and are kept. Best-effort: a failed queue read/delete is
243
+ # logged and swallowed (returns []), and the later schedule surfaces LC068 the old way rather than
244
+ # this raising. Moved out of the app's raw Redfish (radfish #40).
245
+ def drain_pending_config_jobs!
246
+ resp = authenticated_request(:get, "/redfish/v1/Managers/iDRAC.Embedded.1/Jobs?$expand=*($levels=1)")
247
+ return [] unless resp.status.to_i == 200
248
+ pending = (JSON.parse(resp.body)["Members"] || []).reject { |j| j["JobState"].to_s == "Completed" }
249
+ return [] if pending.empty?
250
+ puts "Draining #{pending.size} pending config job(s) that would block a new one (LC068): " \
251
+ "#{pending.map { |j| "#{j['Id']}=#{j['JobState']}" }.join(', ')}".yellow
252
+ pending.each { |j| authenticated_request(:delete, "/redfish/v1/Managers/iDRAC.Embedded.1/Jobs/#{j['Id']}") }
253
+ pending.map { |j| j["Id"] }
254
+ rescue StandardError => e
255
+ puts "Could not drain pending config jobs (#{e.class}: #{e.message.lines.first.to_s.strip}); proceeding.".yellow
256
+ []
257
+ end
258
+
259
+ # Job states that mean a BIOS/config job has finished, one way or another.
260
+ CONFIG_JOB_TERMINAL_STATES = %w[Completed Failed CompletedWithErrors].freeze
261
+
262
+ # Poll a Lifecycle Controller job (e.g. the BIOS config job that a BootSources change schedules)
263
+ # until it reaches a terminal state, and RETURN that state string. Returns nil on timeout rather
264
+ # than raising -- the caller proceeds without confirmation (a still-pending job costs a slower
265
+ # boot, not a wrong one). A transient read error is treated as "keep polling". Moved out of the
266
+ # app's raw Redfish (radfish #40).
267
+ def wait_config_job(jid, timeout: 900, interval: 20)
268
+ deadline = Time.now + timeout
269
+ loop do
270
+ state = begin
271
+ res = authenticated_request(:get, "/redfish/v1/Managers/iDRAC.Embedded.1/Jobs/#{jid}")
272
+ body = res.body.is_a?(String) ? JSON.parse(res.body) : res.body
273
+ body.is_a?(Hash) ? body["JobState"] : nil
274
+ rescue StandardError
275
+ nil
276
+ end
277
+ return state if CONFIG_JOB_TERMINAL_STATES.include?(state)
278
+ if Time.now > deadline
279
+ puts "Config job #{jid} did not finish within #{timeout}s (last state #{state.inspect}); " \
280
+ "proceeding without confirmation.".yellow
281
+ return nil
282
+ end
283
+ sleep interval
284
+ end
285
+ end
286
+
237
287
  # Get system tasks
238
288
  def tasks
239
289
  response = authenticated_request(:get, '/redfish/v1/TaskService/Tasks')
@@ -90,7 +90,13 @@ module IDRAC
90
90
  #
91
91
  # { status: :success | :failed | :timeout, job_id:, job_state:, message:,
92
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)
93
+ def set_system_configuration_profile(scp, target: "ALL", reboot: false, timeout: 600, retry_count: 0, drain: true)
94
+ # A stale Lifecycle Controller job left pending/scheduled makes this import hard-fail with
95
+ # RED/LC068 ("a configuration job is already scheduled"). Anticipate it: drain any still-
96
+ # incomplete config job first (self-heal, don't hard-fail) so the import goes through. Pass
97
+ # drain: false to keep an in-flight job (e.g. when a long-running job must not be disturbed).
98
+ drain_pending_config_jobs! if drain
99
+
94
100
  # Ensure scp has the proper structure with SystemConfiguration wrapper
95
101
  scp_to_apply = if scp.is_a?(Hash) && scp["SystemConfiguration"]
96
102
  scp
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.6"
4
+ VERSION = "0.11.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.10.6
4
+ version: 0.11.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: 2026-09-10 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: httparty
@@ -281,6 +282,7 @@ licenses:
281
282
  metadata:
282
283
  homepage_uri: https://github.com/buildio/idrac
283
284
  source_code_uri: https://github.com/buildio/idrac
285
+ post_install_message:
284
286
  rdoc_options: []
285
287
  require_paths:
286
288
  - lib
@@ -295,7 +297,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
295
297
  - !ruby/object:Gem::Version
296
298
  version: '0'
297
299
  requirements: []
298
- rubygems_version: 3.6.9
300
+ rubygems_version: 3.5.16
301
+ signing_key:
299
302
  specification_version: 4
300
303
  summary: API Client for Dell iDRAC
301
304
  test_files: []