lib/idrac/boot.rb



require 'json'
require 'colorize'

########################################################
# BIOS Configuration / Boot Order
########################################################
# BEWARE YE WHO ENTER HERE
# This is the BIOS configuration and boot order section.
# It is a dark and dangerous place, fraught with peril.
#
# BIOS and UEFI and iDRAC all interplay through a handful of REST API calls and
# a labyrinth of system configuration profile settings. You must know if you are
# in UEFI or BIOS mode to even know which calls to make and some calls "unlock"
# only AFTER you make a switch between modes. Which requires an explicit reboot.
#
# Two current open issues remain:
#  - How do you avoid booting from an installed USB with a bootable image? (workaround--wipefs the USB)
#  - How do you boot-once to the Virtual CD, install Ubuntu, on its natural reboot step, boot to the HD. (workaround--finish install with poweroff)
#
# Get oriented:
# https://github.com/dell/dellemc-openmanage-ansible-modules/issues/21
# https://www.dell.com/support/manuals/en-us/openmanage-ansible-modules/user_guide_1_0_1/configuring-bios?guid=guid-d2d8d871-c3e1-48d1-a879-197670fe33ea&lang=en-us
# https://www.dell.com/support/manuals/en-us/idrac7-8-lifecycle-controller-v2.40.40.40/redfish%202.40.40.40/computersystem?guid=guid-071f0516-1b31-4a4b-90ab-4f9bfcc5db4a&lang=en-us
# https://infohub.delltechnologies.com/en-US/l/server-configuration-profiles-reference-guide/changing-the-boot-order-2/
# https://pubs.lenovo.com/xcc-restapi/update_next_onetime_bootconfig_patch
# https://github.com/dell/iDRAC-Redfish-Scripting/issues/186
# https://www.dell.com/support/kbdoc/en-us/000198504/boot-device-fqdd-name-changed-in-15g-bios-uefi-boot-sequence-after-bios-update
# https://github.com/dell/iDRAC-Redfish-Scripting/issues/116
module IDRAC
  module Boot
    # Get BIOS boot options
    def get_bios_boot_options
      response = authenticated_request(:get, "/redfish/v1/Systems/System.Embedded.1/BootSources")
      
      if response.status == 200
        begin
          data = JSON.parse(response.body)
          
          if data["Attributes"]["UefiBootSeq"].blank?
            puts "Not in UEFI mode".red
            return false
          end
          
          boot_order = []
          boot_options = []
          
          data["Attributes"]["UefiBootSeq"].each do |seq|
            puts "#{seq["Name"]} > #{seq["Enabled"]}".yellow
            boot_options << seq["Name"]
            boot_order << seq["Name"] if seq["Enabled"]
          end
          
          return {
            boot_options: boot_options,
            boot_order: boot_order
          }
        rescue JSON::ParserError
          raise Error, "Failed to parse BIOS boot options response: #{response.body}"
        end
      else
        raise Error, "Failed to get BIOS boot options. Status code: #{response.status}"
      end
    end
    
    # Ensure UEFI boot mode
    def ensure_uefi_boot
      response = authenticated_request(:get, "/redfish/v1/Systems/System.Embedded.1/Bios")
      
      if response.status == 200
        begin
          data = JSON.parse(response.body)
          
          if data["Attributes"]["BootMode"] == "Uefi"
            puts "System is already in UEFI boot mode".green
            return true
          else
            puts "System is not in UEFI boot mode. Setting to UEFI...".yellow
            
            # Create payload for UEFI boot mode
            payload = {
              "Attributes": {
                "BootMode": "Uefi"
              }
            }
            
            # If iDRAC 9, we need to enable HddPlaceholder
            if get_idrac_version == 9
              payload[:Attributes][:HddPlaceholder] = "Enabled"
            end
            
            response = authenticated_request(
              :patch, 
              "/redfish/v1/Systems/System.Embedded.1/Bios/Settings",
              body: payload.to_json,
              headers: { 'Content-Type': 'application/json' }
            )
            
            wait_for_job(response.headers["location"])
          end
        rescue JSON::ParserError
          raise Error, "Failed to parse BIOS response: #{response.body}"
        end
      else
        raise Error, "Failed to get BIOS information. Status code: #{response.status}"
      end
    end
=begin
    # Servers can boot in BIOS mode or in UEFI (modern, extensible BIOS replacement) mode.
    # We use UEFI mode.
    # self.get(path: "Systems/System.Embedded.1/Bios/Settings?$select=BootMode")
    res = self.get(path: "Systems/System.Embedded.1/Bios")
    if res["body"]["Attributes"]["BootMode"] == "Uefi"
      return { status: :success }
    else
      res = self.set_system_configuration_profile(scp_boot_mode_uefi, reboot: true)
      # Then must power cycle the server
      self.power_on!(wait: true)
      self.power_off!(wait: true)
      return res
    end

=end
    def scp_boot_mode_uefi(idrac_license_version: 9)
      opts = { "BootMode" => 'Uefi' }
      # If we're iDRAC 9, we need enable a placeholder, otherwise we can't order the
      # boot order until we've switched to UEFI mode.
      # Read [about it](https://dl.dell.com/manuals/all-products/esuprt_software/esuprt_it_ops_datcentr_mgmt/dell-management-solution-resources_white-papers12_en-us.pdf).
      # ...administrators may wish to reserve a boot entry for a fixed disk in the UEFI Boot Sequence before an OS is installed or before a physical or
      # virtual drive has been formatted. When a HardDisk Drive Placeholder is set to Enabled, the BIOS will create a boot option for the PERC RAID
      # (Integrated or in a PCIe slot) disk if a partition is found, even if there is no FAT filesystem present... this allows the Integrated RAID controller
      # to be moved in the UEFI Boot Sequence prior to the OS installation
      opts["HddPlaceholder"] = "Enabled" if idrac_license_version.to_i == 9
      self.make_scp(fqdd: "BIOS.Setup.1-1", attributes: opts)
    end
    # What triggers a reboot?
    # https://infohub.delltechnologies.com/en-US/l/server-configuration-profiles-reference-guide/host-reboot-2/
    def set_bios(hash)
      scp = self.make_scp(fqdd: "BIOS.Setup.1-1", attributes: hash)
      res = self.set_system_configuration_profile(scp)
      if res[:status] == :success
        self.get_bios_boot_options
      end
      res
    end
    
    # Set boot order (HD first)
    def set_boot_order_hd_first
      # First ensure we're in UEFI mode
      ensure_uefi_boot
      
      # Get available boot options
      boot_options_response = authenticated_request(:get, "/redfish/v1/Systems/System.Embedded.1/BootOptions?$expand=*($levels=1)")
      
      if boot_options_response.status == 200
        begin
          data = JSON.parse(boot_options_response.body)
          
          puts "Available boot options:"
          data["Members"].each { |m| puts "\t#{m['DisplayName']} -> #{m['Id']}" }
          
          # Find RAID controller or HD
          device = data["Members"].find { |m| m["DisplayName"] =~ /RAID Controller/ }
          # Sometimes it's named differently
          device ||= data["Members"].find { |m| m["DisplayName"] =~ /ubuntu/i }
          device ||= data["Members"].find { |m| m["DisplayName"] =~ /UEFI Hard Drive/i }
          device ||= data["Members"].find { |m| m["DisplayName"] =~ /Hard Drive/i }
          
          if device.nil?
            raise Error, "No bootable hard drive or RAID controller found in boot options"
          end
          
          boot_id = device["Id"]
          
          # Set boot order
          response = authenticated_request(
            :patch, 
            "/redfish/v1/Systems/System.Embedded.1",
            body: { "Boot": { "BootOrder": [boot_id] } }.to_json,
            headers: { 'Content-Type': 'application/json' }
          )
          
          if response.status.between?(200, 299)
            puts "Boot order set to HD first".green
            return true
          else
            error_message = "Failed to set boot order. Status code: #{response.status}"
            
            begin
              error_data = JSON.parse(response.body)
              if error_data["error"] && error_data["error"]["@Message.ExtendedInfo"]
                error_info = error_data["error"]["@Message.ExtendedInfo"].first
                error_message += ", Message: #{error_info['Message']}"
              end
            rescue
              # Ignore JSON parsing errors
            end
            
            raise Error, error_message
          end
        rescue JSON::ParserError
          raise Error, "Failed to parse boot options response: #{response.body}"
        end
      else
        raise Error, "Failed to get boot options. Status code: #{boot_options_response.status}"
      end
    end

    def set_uefi_boot_cd_once_then_hd
      boot_options = get_bios_boot_options[:boot_options]
      # Note may have to put device into
      # self.set_bios( { "BootMode" => 'Uefi' } )
      # self.reboot!
      # And then reboot before you can make the following call:
      raid_name = boot_options.include?("RAID.Integrated.1-1") ? "RAID.Integrated.1-1" : "Unknown.Unknown.1-1"
      raise "No RAID HD in boot options" unless boot_options.include?(raid_name)
      bios = {
          "BootMode" => 'Uefi',
          "BootSeqRetry" => "Disabled",

          # "UefiTargetBootSourceOverride" => 'Cd',
          # "BootSourceOverrideTarget" => 'UefiTarget',
          # "OneTimeBootMode"       => "OneTimeUefiBootSeq",

          # One time boot order
          # "OneTimeHddSeqDev"      => "Optical.iDRACVirtual.1-1",
          # "OneTimeBiosBootSeqDev" => "Optical.iDRACVirtual.1-1",
          # "OneTimeUefiBootSeqDev" => "Optical.iDRACVirtual.1-1",

          # Enabled/Disabled Options
          # "SetBootOrderDis" => "Disk.USBBack.1-1",  # Don't boot to USB if it is plugged in
          "SetBootOrderEn"    => raid_name,
          # "SetBootOrderFqdd1" => raid_name,
          # "SetLegacyHddOrderFqdd1" => raid_name,
          # "SetBootOrderFqdd2" => "Optical.iDRACVirtual.1-1",

          # Permanent Boot Order
          "HddSeq"      => raid_name,
          "BiosBootSeq" => raid_name,
          "UefiBootSeq" => raid_name # This is likely redundant...
        }
      # The usb device will have 'usb' in it:
      usb_name = boot_options.select { |b| b =~ /usb/i }
      bios["SetBootOrderDis"] = usb_name if usb_name.present?

      set_bios(bios)
    end

    # This sets boot to HD but before that it sets the one-time boot to CD
    # Different approach for iDRAC 8 vs 9
    def override_boot_source
      # For now try with all iDRAC versions
      if self.license_version.to_i == 9
        set_boot_order_hd_first()
        set_one_time_virtual_media_boot()
      else
        scp = {"FQDD"=>"iDRAC.Embedded.1", "Attributes"=> [{"Name"=>"ServerBoot.1#BootOnce", "Value"=>"Enabled", "Set On Import"=>"True"}, {"Name"=>"ServerBoot.1#FirstBootDevice", "Value"=>"VCD-DVD", "Set On Import"=>"True"}]}
        # set_uefi_boot_cd_once_then_hd
        # scp = self.set_bios_boot_cd_first
        # get_bios_boot_options # Make sure we know if the OS is calling it Unknown or RAID
        # {"FQDD"=>"BIOS.Setup.1-1", "Attributes"=>
        # [{"Name"=>"ServerBoot.1#BootOnce",       "Value"=>"Enabled", "Set On Import"=>"True"},
        # {"Name"=>"ServerBoot.1#FirstBootDevice", "Value"=>"VCD-DVD", "Set On Import"=>"True"},
        # {"Name"=>"BootSeqRetry",                 "Value"=>"Disabled", "Set On Import"=>"True"},
        # {"Name"=>"UefiBootSeq",                  "Value"=>"Unknown.Unknown.1-1,NIC.PxeDevice.1-1,Floppy.iDRACVirtual.1-1,Optical.iDRACVirtual.1-1",
        #  "Set On Import"=>"True"}]}

        # 3.3.0 :018 > scp1 = {"FQDD"=>"BIOS.Setup.1-1", "Attributes"=> [{"Name"=>"OneTimeUefiBootSeq", "Value"=>"VCD-DVD", "Set On Import"=>"True"}, {"Name"=>"BootSeqRetry", "Value"=>"Disabled", "Set On Import"=>"True"}, {"Name"=>"UefiBootSeq", "Value"=>"Unknown.Unknown.1-1,NIC.PxeDevice.1-1", "Set On Import"=>"True"}]}
        set_system_configuration_profile(scp) # This will cycle power and leave the device off.
      end
    end
    
    # Configure BIOS settings
    def configure_bios_settings(settings)
      response = authenticated_request(
        :patch, 
        "/redfish/v1/Systems/System.Embedded.1/Bios/Settings",
        body: { "Attributes": settings }.to_json,
        headers: { 'Content-Type': 'application/json' }
      )
      
      if response.status.between?(200, 299)
        puts "BIOS settings configured. A system reboot is required for changes to take effect.".green
        
        # Check if we need to wait for a job
        if response.headers["Location"]
          job_id = response.headers["Location"].split("/").last
          wait_for_job(job_id)
        end
        
        return true
      else
        error_message = "Failed to configure BIOS settings. Status code: #{response.status}"
        
        begin
          error_data = JSON.parse(response.body)
          if error_data["error"] && error_data["error"]["@Message.ExtendedInfo"]
            error_info = error_data["error"]["@Message.ExtendedInfo"].first
            error_message += ", Message: #{error_info['Message']}"
          end
        rescue
          # Ignore JSON parsing errors
        end
        
        raise Error, error_message
      end
    end
    
    # Configure BIOS to optimize for OS power management
    def set_bios_os_power_control
      settings = {
        "ProcCStates": "Enabled",      # Processor C-States
        "SysProfile": "PerfPerWattOptimizedOs",
        "ProcPwrPerf": "OsDbpm",       # OS Power Management
        "PcieAspmL1": "Enabled"        # PCIe Active State Power Management
      }
      
      configure_bios_settings(settings)
    end
    
    # Configure BIOS to ignore boot errors
    def set_bios_ignore_errors(value = true)
      configure_bios_settings({
        "ErrPrompt": value ? "Disabled" : "Enabled"
      })
    end
    
    # Check if BIOS error prompt is disabled
    def bios_error_prompt_disabled?
      response = authenticated_request(:get, "/redfish/v1/Systems/System.Embedded.1/Bios")
      
      if response.status == 200
        begin
          data = JSON.parse(response.body)
          if data["Attributes"] && data["Attributes"].has_key?("ErrPrompt")
            return data["Attributes"]["ErrPrompt"] == "Disabled"
          else
            debug "ErrPrompt attribute not found in BIOS settings", 1, :yellow
            return false
          end
        rescue JSON::ParserError
          debug "Failed to parse BIOS response", 0, :red
          return false
        end
      else
        debug "Failed to get BIOS information. Status code: #{response.status}", 0, :red
        return false
      end
    end

    def bios_hdd_placeholder_enabled?
      case self.license_version.to_i
      when 8
        # scp = usable_scp(get_system_configuration_profile(target: "BIOS"))
        # scp["BIOS.Setup.1-1"]["HddPlaceholder"] == "Enabled"
        true
      else
        response = authenticated_request(:get, "/redfish/v1/Systems/System.Embedded.1/Bios")
        json = JSON.parse(response.body)
        raise "Error reading HddPlaceholder setup" if json&.dig('SystemConfiguration').blank?
        json["Attributes"]["HddPlaceholder"] == "Enabled"
      end
    end

    def bios_os_power_control_enabled?
      case self.license_version.to_i
      when 8
        scp = usable_scp(get_system_configuration_profile(target: "BIOS"))
        scp["BIOS.Setup.1-1"]["ProcCStates"] == "Enabled" &&
          scp["BIOS.Setup.1-1"]["SysProfile"] == "PerfPerWattOptimizedOs" &&
          scp["BIOS.Setup.1-1"]["ProcPwrPerf"] == "OsDbpm"
      else
        response = authenticated_request(:get, "/redfish/v1/Systems/System.Embedded.1/Bios")
        json = JSON.parse(response.body)
        raise "Error reading PowerControl setup" if json&.dig('SystemConfiguration').blank?
        json["Attributes"]["ProcCStates"] == "Enabled" &&
          json["Attributes"]["SysProfile"] == "PerfPerWattOptimizedOs" &&
          json["Attributes"]["ProcPwrPerf"] == "OsDbpm"
      end
    end
    
    # Get iDRAC version - needed for boot management differences
    def get_idrac_version
      response = authenticated_request(:get, "/redfish/v1")
      
      if response.status == 200
        begin
          data = JSON.parse(response.body)
          redfish = data["RedfishVersion"]
          server = response.headers["server"]
          
          case server.to_s.downcase
          when /appweb\/4.5.4/, /idrac\/8/
            return 8
          when /apache/, /idrac\/9/
            return 9
          else
            # Try to determine by RedfishVersion as fallback
            if redfish == "1.4.0"
              return 8
            elsif redfish == "1.18.0"
              return 9
            else
              raise Error, "Unknown iDRAC version: #{server} / #{redfish}"
            end
          end
        rescue JSON::ParserError
          raise Error, "Failed to parse iDRAC response: #{response.body}"
        end
      else
        raise Error, "Failed to get iDRAC information. Status code: #{response.status}"
      end
    end
    
    # Create System Configuration Profile for BIOS settings
    def create_scp_for_bios(settings)
      attributes = []
      
      settings.each do |key, value|
        attributes << {
          "Name": key.to_s,
          "Value": value,
          "Set On Import": "True"
        }
      end
      
      scp = {
        "SystemConfiguration": {
          "Components": [
            {
              "FQDD": "BIOS.Setup.1-1",
              "Attributes": attributes
            }
          ]
        }
      }
      
      return scp
    end
    
    # Import System Configuration Profile for advanced configurations
    def import_system_configuration(scp, target: "ALL", reboot: false)
      params = {
        "ImportBuffer": JSON.pretty_generate(scp),
        "ShareParameters": {
          "Target": target
        }
      }
      # Configure shutdown behavior
      params["ShutdownType"] = "Forced"
      params["HostPowerState"] = reboot ? "On" : "Off"
      
      response = authenticated_request(
        :post, 
        "/redfish/v1/Managers/iDRAC.Embedded.1/Actions/Oem/EID_674_Manager.ImportSystemConfiguration",
        body: params.to_json,
        headers: { 'Content-Type': 'application/json' }
      )
      
      task = wait_for_task(response.headers["location"])
      debugger
      return task
    end
  end
end