lib/idrac/firmware.rb



require 'tempfile'
require 'net/http'
require 'uri'
require 'json'
require 'nokogiri'
require 'fileutils'
require 'securerandom'
require 'set'
require 'colorize'
require_relative 'firmware_catalog'
require 'faraday'
require 'faraday/multipart'

module IDRAC
  class Firmware
    attr_reader :client

    CATALOG_URL = "https://downloads.dell.com/catalog/Catalog.xml.gz"
    
    def initialize(client)
      @client = client
    end

    def update(firmware_path, options = {})
      # Validate firmware file exists
      unless File.exist?(firmware_path)
        raise Error, "Firmware file not found: #{firmware_path}"
      end

      # Ensure we have a client
      raise Error, "Client is required for firmware update" unless client

      # Login to iDRAC
      client.login unless client.instance_variable_get(:@session_id)

      # Upload firmware file
      job_id = upload_firmware(firmware_path)
      
      # Check if we should wait for the update to complete
      if options[:wait]
        wait_for_job_completion(job_id, options[:timeout] || 3600)
      end

      job_id
    end
    
    def download_catalog(output_dir = nil)
      # Use the new FirmwareCatalog class
      catalog = FirmwareCatalog.new
      catalog.download(output_dir)
    end
    
    def get_system_inventory
      # Ensure we have a client
      raise Error, "Client is required for system inventory" unless client
      
      puts "Retrieving system inventory..."
      
      # Get basic system information
      system_uri = URI.parse("#{client.base_url}/redfish/v1/Systems/System.Embedded.1")
      system_response = client.authenticated_request(:get, "/redfish/v1/Systems/System.Embedded.1")
      
      if system_response.status != 200
        raise Error, "Failed to get system information: #{system_response.status}"
      end
      
      system_data = JSON.parse(system_response.body)
      
      # Get firmware inventory
      firmware_uri = URI.parse("#{client.base_url}/redfish/v1/UpdateService/FirmwareInventory")
      firmware_response = client.authenticated_request(:get, "/redfish/v1/UpdateService/FirmwareInventory")
      
      if firmware_response.status != 200
        raise Error, "Failed to get firmware inventory: #{firmware_response.status}"
      end
      
      firmware_data = JSON.parse(firmware_response.body)
      
      # Get detailed firmware information for each component
      firmware_inventory = []
      
      if firmware_data['Members'] && firmware_data['Members'].is_a?(Array)
        firmware_data['Members'].each do |member|
          if member['@odata.id']
            component_uri = member['@odata.id']
            component_response = client.authenticated_request(:get, component_uri)
            
            if component_response.status == 200
              component_data = JSON.parse(component_response.body)
              firmware_inventory << {
                name: component_data['Name'],
                id: component_data['Id'],
                version: component_data['Version'],
                updateable: component_data['Updateable'] || false,
                status: component_data['Status'] ? component_data['Status']['State'] : 'Unknown'
              }
            end
          end
        end
      end
      
      {
        system: {
          model: system_data['Model'],
          manufacturer: system_data['Manufacturer'],
          serial_number: system_data['SerialNumber'],
          part_number: system_data['PartNumber'],
          bios_version: system_data['BiosVersion'],
          service_tag: system_data['SKU']
        },
        firmware: firmware_inventory
      }
    end
    
    def check_updates(catalog_path = nil)
      # Ensure we have a client for system inventory
      raise Error, "Client is required for checking updates" unless client
      
      # Download catalog if not provided
      catalog_path ||= download_catalog
      
      # Get system inventory
      inventory = get_system_inventory
      
      # Create a FirmwareCatalog instance
      catalog = FirmwareCatalog.new(catalog_path)
      
      # Extract system information
      system_model = inventory[:system][:model]
      service_tag = inventory[:system][:service_tag]
      
      puts "Checking updates for system with service tag: #{service_tag}".light_cyan
      puts "Searching for updates for model: #{system_model}".light_cyan
      
      # Find system models in the catalog
      models = catalog.find_system_models(system_model)
      
      if models.empty?
        puts "No matching system model found in catalog".yellow
        return []
      end
      
      # Use the first matching model
      model = models.first
      puts "Found system IDs for #{model[:name]}: #{model[:id]}".green
      
      # Find updates for this system
      catalog_updates = catalog.find_updates_for_system(model[:id])
      puts "Found #{catalog_updates.size} firmware updates for #{model[:name]}".green
      
      # Compare current firmware with available updates
      updates = []
      
      # Print header for firmware comparison table
      puts "\nFirmware Version Comparison:".green.bold
      puts "=" * 100
      puts "%-30s %-20s %-20s %-10s %-15s %s" % ["Component", "Current Version", "Available Version", "Updateable", "Category", "Status"]
      puts "-" * 100
      
      # Track components we've already displayed to avoid duplicates
      displayed_components = Set.new
      
      # First show current firmware with available updates
      inventory[:firmware].each do |fw|
        # Make sure firmware name is not nil
        firmware_name = fw[:name] || ""
        
        # Skip if we've already displayed this component
        next if displayed_components.include?(firmware_name.downcase)
        displayed_components.add(firmware_name.downcase)
        
        # Extract key identifiers from the firmware name
        identifiers = extract_identifiers(firmware_name)
        
        # Try to find a matching update
        matching_updates = catalog_updates.select do |update|
          update_name = update[:name] || ""
          
          # Check if any of our identifiers match the update name
          identifiers.any? { |id| update_name.downcase.include?(id.downcase) } ||
          # Or if the update name contains the firmware name
          update_name.downcase.include?(firmware_name.downcase) ||
          # Or if the firmware name contains the update name
          firmware_name.downcase.include?(update_name.downcase)
        end
        
        if matching_updates.any?
          # Use the first matching update
          update = matching_updates.first
          
          # Check if version is newer
          needs_update = catalog.compare_versions(fw[:version], update[:version])
          
          # Add to updates list if needed
          if needs_update && fw[:updateable]
            updates << {
              name: fw[:name],
              current_version: fw[:version],
              available_version: update[:version],
              path: update[:path],
              component_type: update[:component_type],
              category: update[:category],
              download_url: update[:download_url]
            }
            
            # Print row with update available
            puts "%-30s %-20s %-20s %-10s %-15s %s" % [
              fw[:name].to_s[0..29],
              fw[:version],
              update[:version],
              fw[:updateable] ? "Yes".light_green : "No".light_red,
              update[:category] || "N/A",
              "UPDATE AVAILABLE".light_green.bold
            ]
          else
            # Print row with no update needed
            status = if !needs_update
                       "Current".light_blue
                     elsif !fw[:updateable]
                       "Not updateable".light_red
                     else
                       "No update needed".light_yellow
                     end
            
            puts "%-30s %-20s %-20s %-10s %-15s %s" % [
              fw[:name].to_s[0..29],
              fw[:version],
              update[:version] || "N/A",
              fw[:updateable] ? "Yes".light_green : "No".light_red,
              update[:category] || "N/A",
              status
            ]
          end
        else
          # No matching update found
          puts "%-30s %-20s %-20s %-10s %-15s %s" % [
            fw[:name].to_s[0..29],
            fw[:version],
            "N/A",
            fw[:updateable] ? "Yes".light_green : "No".light_red,
            "N/A",
            "No update available".light_yellow
          ]
        end
      end
      
      # Then show available updates that don't match any current firmware
      catalog_updates.each do |update|
        update_name = update[:name] || ""
        
        # Skip if we've already displayed this component
        next if displayed_components.include?(update_name.downcase)
        displayed_components.add(update_name.downcase)
        
        # Skip if this update was already matched to a current firmware
        next if inventory[:firmware].any? do |fw|
          firmware_name = fw[:name] || ""
          identifiers = extract_identifiers(firmware_name)
          
          identifiers.any? { |id| update_name.downcase.include?(id.downcase) } ||
          update_name.downcase.include?(firmware_name.downcase) ||
          firmware_name.downcase.include?(update_name.downcase)
        end
        
        puts "%-30s %-20s %-20s %-10s %-15s %s" % [
          update_name.to_s[0..29],
          "Not Installed".light_red,
          update[:version] || "Unknown",
          "N/A",
          update[:category] || "N/A",
          "NEW COMPONENT".light_blue
        ]
      end
      
      puts "=" * 100
      
      updates
    end
    
    def interactive_update(catalog_path = nil, selected_updates = nil)
      # Check if updates are available
      updates = selected_updates || check_updates(catalog_path)
      
      if updates.empty?
        puts "No updates available for your system.".yellow
        return
      end
      
      # Display available updates
      puts "\nAvailable Updates:".green.bold
      updates.each_with_index do |update, index|
        puts "#{index + 1}. #{update[:name]}: #{update[:current_version]} -> #{update[:available_version]}".light_cyan
      end
      
      # If no specific updates were selected, ask the user which ones to install
      if selected_updates.nil?
        puts "\nEnter the number of the update to install (or 'all' for all updates, 'q' to quit):".light_yellow
        input = STDIN.gets.chomp
        
        if input.downcase == 'q'
          puts "Update cancelled.".yellow
          return
        elsif input.downcase == 'all'
          selected_updates = updates
        else
          begin
            index = input.to_i - 1
            if index >= 0 && index < updates.length
              selected_updates = [updates[index]]
            else
              puts "Invalid selection. Please enter a number between 1 and #{updates.length}.".red
              return
            end
          rescue
            puts "Invalid input. Please enter a number, 'all', or 'q'.".red
            return
          end
        end
      end
      
      # Process each selected update
      selected_updates.each do |update|
        puts "\nDownloading #{update[:name]} version #{update[:available_version]}...".light_cyan
        
        begin
          # Download the firmware
          firmware_file = download_firmware(update)
          
          if firmware_file
            puts "Installing #{update[:name]} version #{update[:available_version]}...".light_cyan
            
            begin
              # Upload and install the firmware
              job_id = upload_firmware(firmware_file)
              
              if job_id
                puts "Firmware update job created with ID: #{job_id}".green
                
                # Wait for the job to complete
                success = wait_for_job_completion(job_id, 1800) # 30 minutes timeout
                
                if success
                  puts "Successfully updated #{update[:name]} to version #{update[:available_version]}".green.bold
                else
                  puts "Failed to update #{update[:name]}. Check the iDRAC web interface for more details.".red
                  puts "You may need to wait for any existing jobs to complete before trying again.".yellow
                end
              else
                puts "Failed to create update job for #{update[:name]}".red
              end
            rescue IDRAC::Error => e
              if e.message.include?("already in progress")
                puts "Error: A firmware update is already in progress.".red.bold
                puts "Please wait for the current update to complete before starting another.".yellow
                puts "You can check the status in the iDRAC web interface under Maintenance > System Update.".light_cyan
              elsif e.message.include?("job ID not found") || e.message.include?("Failed to get job status")
                puts "Error: Could not monitor the update job.".red.bold
                puts "The update may still be in progress. Check the iDRAC web interface for status.".yellow
                puts "This can happen if the iDRAC is busy processing the update request.".light_cyan
              else
                puts "Error during firmware update: #{e.message}".red.bold
              end
              
              # If we encounter an error with one update, ask if the user wants to continue with others
              if selected_updates.length > 1 && update != selected_updates.last
                puts "\nDo you want to continue with the remaining updates? (y/n)".light_yellow
                continue = STDIN.gets.chomp.downcase
                break unless continue == 'y'
              end
            end
          else
            puts "Failed to download firmware for #{update[:name]}".red
          end
        rescue => e
          puts "Error processing update for #{update[:name]}: #{e.message}".red.bold
          
          # If we encounter an error with one update, ask if the user wants to continue with others
          if selected_updates.length > 1 && update != selected_updates.last
            puts "\nDo you want to continue with the remaining updates? (y/n)".light_yellow
            continue = STDIN.gets.chomp.downcase
            break unless continue == 'y'
          end
        ensure
          # Clean up temporary files
          FileUtils.rm_f(firmware_file) if firmware_file && File.exist?(firmware_file)
        end
      end
    end

    def download_firmware(update)
      return false unless update && update[:download_url]
      
      begin
        # Create a temporary directory for the download
        temp_dir = Dir.mktmpdir
        
        # Extract the filename from the path
        filename = File.basename(update[:path])
        local_path = File.join(temp_dir, filename)
        
        puts "Downloading firmware from #{update[:download_url]}".light_cyan
        puts "Saving to #{local_path}".light_cyan
        
        # Download the file
        uri = URI.parse(update[:download_url])
        
        Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', verify_mode: OpenSSL::SSL::VERIFY_NONE) do |http|
          request = Net::HTTP::Get.new(uri)
          
          http.request(request) do |response|
            if response.code == "200"
              File.open(local_path, 'wb') do |file|
                response.read_body do |chunk|
                  file.write(chunk)
                end
              end
              
              puts "Download completed successfully".green
              return local_path
            else
              puts "Failed to download firmware: #{response.code} #{response.message}".red
              return false
            end
          end
        end
      rescue => e
        puts "Error downloading firmware: #{e.message}".red.bold
        return false
      end
    end

    def get_power_state
      # Ensure we have a client
      raise Error, "Client is required for power management" unless client
      
      # Login to iDRAC if needed
      client.login unless client.instance_variable_get(:@session_id)
      
      # Get system information
      response = client.authenticated_request(:get, "/redfish/v1/Systems/System.Embedded.1")
      
      if response.status == 200
        system_data = JSON.parse(response.body)
        return system_data["PowerState"]
      else
        raise Error, "Failed to get power state. Status code: #{response.status}"
      end
    end

    private

    def upload_firmware(firmware_path)
      puts "Uploading firmware file: #{firmware_path}".light_cyan
      
      begin
        # First, get the HttpPushUri from the UpdateService
        response = client.authenticated_request(
          :get,
          "/redfish/v1/UpdateService"
        )
        
        if response.status != 200
          puts "Failed to get UpdateService information: #{response.status}".red
          raise Error, "Failed to get UpdateService information: #{response.status}"
        end
        
        update_service = JSON.parse(response.body)
        http_push_uri = update_service['HttpPushUri']
        
        if http_push_uri.nil?
          puts "HttpPushUri not found in UpdateService".red
          raise Error, "HttpPushUri not found in UpdateService"
        end
        
        puts "Found HttpPushUri: #{http_push_uri}".light_cyan
        
        # Get the ETag for the firmware inventory
        etag_response = client.authenticated_request(
          :get,
          http_push_uri
        )
        
        if etag_response.status != 200
          puts "Failed to get ETag: #{etag_response.status}".red
          raise Error, "Failed to get ETag: #{etag_response.status}"
        end
        
        etag = etag_response.headers['ETag']
        
        if etag.nil?
          puts "ETag not found in response headers".yellow
          # Some iDRACs don't require ETag, so we'll continue
        else
          puts "Got ETag: #{etag}".light_cyan
        end
        
        # Upload the firmware file
        file_content = File.read(firmware_path)
        
        headers = {
          'Content-Type' => 'multipart/form-data',
          'If-Match' => etag
        }
        
        # Create a temp file for multipart upload
        upload_io = Faraday::UploadIO.new(firmware_path, 'application/octet-stream')
        payload = { :file => upload_io }
        
        upload_response = client.authenticated_request(
          :post,
          http_push_uri,
          {
            headers: headers,
            body: payload,
          }
        )
        
        if upload_response.status != 201 && upload_response.status != 200
          puts "Failed to upload firmware: #{upload_response.status} - #{upload_response.body}".red
          
          if upload_response.body.include?("already in progress")
            raise Error, "A deployment or update operation is already in progress. Please wait for it to complete before attempting another update."
          else
            raise Error, "Failed to upload firmware: #{upload_response.status} - #{upload_response.body}"
          end
        end
        
        # Extract the firmware ID from the response
        begin
          upload_data = JSON.parse(upload_response.body)
          firmware_id = upload_data['Id'] || upload_data['@odata.id']&.split('/')&.last
          
          if firmware_id.nil?
            # Try to extract from the Location header
            location = upload_response.headers['Location']
            firmware_id = location&.split('/')&.last
          end
          
          if firmware_id.nil?
            puts "Warning: Could not extract firmware ID from response".yellow
            puts "Response body: #{upload_response.body}"
            # We'll try to continue with the SimpleUpdate action anyway
          else
            puts "Firmware file uploaded successfully with ID: #{firmware_id}".green
          end
        rescue JSON::ParserError => e
          puts "Warning: Could not parse upload response: #{e.message}".yellow
          puts "Response body: #{upload_response.body}"
          # We'll try to continue with the SimpleUpdate action anyway
        end
        
        # Now initiate the firmware update using SimpleUpdate action
        puts "Initiating firmware update using SimpleUpdate...".light_cyan
        
        # Construct the image URI
        image_uri = nil
        
        if firmware_id
          image_uri = "#{http_push_uri}/#{firmware_id}"
        else
          # If we couldn't extract the firmware ID, try using the Location header
          image_uri = upload_response.headers['Location']
        end
        
        # If we still don't have an image URI, try to use the HTTP push URI as a fallback
        if image_uri.nil?
          puts "Warning: Could not determine image URI, using HTTP push URI as fallback".yellow
          image_uri = http_push_uri
        end
        
        puts "Using ImageURI: #{image_uri}".light_cyan
        
        # Initiate the SimpleUpdate action
        simple_update_payload = {
          "ImageURI" => image_uri,
          "TransferProtocol" => "HTTP"
        }
        
        update_response = client.authenticated_request(
          :post,
          "/redfish/v1/UpdateService/Actions/UpdateService.SimpleUpdate",
          {
            headers: { 'Content-Type' => 'application/json' },
            body: simple_update_payload.to_json
          }
        )
        
        if update_response.status != 202 && update_response.status != 200
          puts "Failed to initiate firmware update: #{update_response.status} - #{update_response.body}".red
          raise Error, "Failed to initiate firmware update: #{update_response.status} - #{update_response.body}"
        end
        
        # Extract the job ID from the response
        job_id = nil
        
        # Try to extract from the response body first
        begin
          update_data = JSON.parse(update_response.body)
          job_id = update_data['Id'] || update_data['JobID']
        rescue JSON::ParserError
          # If we can't parse the body, that's okay, we'll try other methods
        end
        
        # If we couldn't get the job ID from the body, try the Location header
        if job_id.nil?
          location = update_response.headers['Location']
          job_id = location&.split('/')&.last
        end
        
        # If we still don't have a job ID, try the response headers
        if job_id.nil?
          # Some iDRACs return the job ID in a custom header
          update_response.headers.each do |key, value|
            if key.downcase.include?('job') && value.is_a?(String) && value.match?(/JID_\d+/)
              job_id = value
              break
            end
          end
        end
        
        # If we still don't have a job ID, check for any JID_ pattern in the response body
        if job_id.nil? && update_response.body.is_a?(String)
          match = update_response.body.match(/JID_\d+/)
          job_id = match[0] if match
        end
        
        # If we still don't have a job ID, check the task service for recent jobs
        if job_id.nil?
          puts "Could not extract job ID from response, checking task service for recent jobs...".yellow
          
          tasks_response = client.authenticated_request(
            :get,
            "/redfish/v1/TaskService/Tasks"
          )
          
          if tasks_response.status == 200
            begin
              tasks_data = JSON.parse(tasks_response.body)
              
              if tasks_data['Members'] && tasks_data['Members'].any?
                # Get the most recent task
                most_recent_task = tasks_data['Members'].first
                task_id = most_recent_task['@odata.id']&.split('/')&.last
                
                if task_id && task_id.match?(/JID_\d+/)
                  job_id = task_id
                  puts "Found recent job ID: #{job_id}".light_cyan
                end
              end
            rescue JSON::ParserError
              # If we can't parse the tasks response, we'll have to give up
            end
          end
        end
        
        if job_id.nil?
          puts "Could not extract job ID from response".red
          raise Error, "Could not extract job ID from response"
        end
        
        puts "Firmware update job created with ID: #{job_id}".green
        return job_id
      rescue => e
        puts "Error during firmware upload: #{e.message}".red.bold
        raise Error, "Error during firmware upload: #{e.message}"
      end
    end

    def wait_for_job_completion(job_id, timeout)
      puts "Waiting for firmware update job #{job_id} to complete...".light_cyan
      
      start_time = Time.now
      last_percent = -1
      
      while Time.now - start_time < timeout
        begin
          status = get_job_status(job_id)
          
          # Only show percentage updates when they change
          if status[:percent_complete] && status[:percent_complete] != last_percent
            puts "Job progress: #{status[:percent_complete]}% complete".light_cyan
            last_percent = status[:percent_complete]
          end
          
          case status[:state]
          when 'Completed'
            puts "Firmware update completed successfully".green
            return true
          when 'Failed', 'CompletedWithErrors'
            message = status[:message] || "Unknown error"
            puts "Firmware update failed: #{message}".red.bold
            return false
          when 'Stopped'
            puts "Firmware update stopped".yellow
            return false
          when 'New', 'Starting', 'Running', 'Pending', 'Scheduled', 'Downloaded', 'Downloading', 'Staged'
            # Job still in progress, continue waiting
            sleep 10
          else
            puts "Unknown job status: #{status[:state]}".yellow
            sleep 10
          end
        rescue => e
          puts "Error checking job status: #{e.message}".red
          puts "Will retry in 15 seconds...".yellow
          sleep 15
        end
      end
      
      puts "Timeout waiting for firmware update to complete".red.bold
      false
    end

    def get_job_status(job_id)
      response = client.authenticated_request(
        :get,
        "/redfish/v1/TaskService/Tasks/#{job_id}"
      )
      
      # Status 202 means the request was accepted but still processing
      # This is normal for jobs that are in progress
      if response.status == 202 || response.status == 200
        begin
          data = JSON.parse(response.body)
          
          # Extract job state and percent complete
          job_state = data.dig('Oem', 'Dell', 'JobState') || data['TaskState']
          percent_complete = data.dig('Oem', 'Dell', 'PercentComplete') || data['PercentComplete']
          
          # Format the percent complete for display
          percent_str = percent_complete.nil? ? "unknown" : "#{percent_complete}%"
          
          puts "Job #{job_id} status: #{job_state} (#{percent_str} complete)".light_cyan
          
          return {
            id: job_id,
            state: job_state,
            percent_complete: percent_complete,
            status: data['TaskStatus'],
            message: data.dig('Oem', 'Dell', 'Message') || (data['Messages'].first && data['Messages'].first['Message']),
            raw_data: data
          }
        rescue JSON::ParserError => e
          puts "Error parsing job status response: #{e.message}".red.bold
          raise Error, "Failed to parse job status response: #{e.message}"
        end
      else
        puts "Failed to get job status with status #{response.status}: #{response.body}".red
        raise Error, "Failed to get job status with status #{response.status}"
      end
    end

    # Helper method to extract identifiers from component names
    def extract_identifiers(name)
      return [] unless name
      
      identifiers = []
      
      # Extract model numbers like X520, I350, etc.
      model_matches = name.scan(/[IX]\d{3,4}/)
      identifiers.concat(model_matches)
      
      # Extract PERC model like H730
      perc_matches = name.scan(/[HP]\d{3,4}/)
      identifiers.concat(perc_matches)
      
      # Extract other common identifiers
      if name.include?("NIC") || name.include?("Ethernet") || name.include?("Network")
        identifiers << "NIC"
      end
      
      if name.include?("PERC") || name.include?("RAID")
        identifiers << "PERC"
        # Extract PERC model like H730
        perc_match = name.match(/PERC\s+([A-Z]\d{3})/)
        identifiers << perc_match[1] if perc_match
      end
      
      if name.include?("BIOS")
        identifiers << "BIOS"
      end
      
      if name.include?("iDRAC") || name.include?("IDRAC") || name.include?("Remote Access Controller")
        identifiers << "iDRAC"
      end
      
      if name.include?("Power Supply") || name.include?("PSU")
        identifiers << "PSU"
      end
      
      if name.include?("Lifecycle Controller")
        identifiers << "LC"
      end
      
      if name.include?("CPLD")
        identifiers << "CPLD"
      end
      
      identifiers
    end
  end
end