# This file is distributed under New Relic's license terms.
# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details.
# frozen_string_literal: true

# This module is intended to provide access to information about the host OS and
# [virtual] machine. It intentionally does no caching and maintains no state -
# caching should be handled by clients if needed. Methods should return nil if
# the requested information is unavailable.

require 'rbconfig'
require 'socket'

module NewRelic
  module Agent
    module SystemInfo
      DOCKER_CGROUPS_V2_PATTERN = %r{.*/docker/containers/([0-9a-f]{64})/.*}.freeze

      def self.ruby_os_identifier
        RbConfig::CONFIG['target_os']
      end

      def self.os_distribution
        case
        when darwin? then :darwin
        when linux? then :linux
        when bsd? then :bsd
        when windows? then :windows
        else ruby_os_identifier
        end
      end

      def self.windows?
        !!(ruby_os_identifier[/mingw|mswin/i])
      end

      def self.darwin?
        !!(ruby_os_identifier =~ /darwin/i)
      end

      def self.linux?
        !!(ruby_os_identifier =~ /linux/i)
      end

      def self.bsd?
        !!(ruby_os_identifier =~ /bsd/i)
      end

      @processor_info = nil

      def self.ip_addresses
        Socket.ip_address_list.map(&:ip_address)
      end

      def self.clear_processor_info
        @processor_info = nil
      end

      def self.processor_info
        return @processor_info if @processor_info

        if darwin?
          processor_info_darwin
        elsif linux?
          processor_info_linux
        elsif bsd?
          processor_info_bsd
        else
          raise "Couldn't determine OS"
        end
        remove_bad_values

        @processor_info
      rescue
        @processor_info = NewRelic::EMPTY_HASH
      end

      def self.processor_info_darwin
        @processor_info = {
          num_physical_packages: sysctl_value('hw.packages'),
          num_physical_cores: sysctl_value('hw.physicalcpu_max'),
          num_logical_processors: sysctl_value('hw.logicalcpu_max')
        }
        # in case those don't work, try backup values
        if @processor_info[:num_physical_cores] <= 0
          @processor_info[:num_physical_cores] = sysctl_value('hw.physicalcpu')
        end
        if @processor_info[:num_logical_processors] <= 0
          @processor_info[:num_logical_processors] = sysctl_value('hw.logicalcpu')
        end
        if @processor_info[:num_logical_processors] <= 0
          @processor_info[:num_logical_processors] = sysctl_value('hw.ncpu')
        end
      end

      def self.processor_info_linux
        cpuinfo = proc_try_read('/proc/cpuinfo')
        @processor_info = cpuinfo ? parse_cpuinfo(cpuinfo) : NewRelic::EMPTY_HASH
      end

      def self.processor_info_bsd
        @processor_info = {
          num_physical_packages: nil,
          num_physical_cores: nil,
          num_logical_processors: sysctl_value('hw.ncpu')
        }
      end

      def self.remove_bad_values
        # give nils for obviously wrong values
        @processor_info.keys.each do |key|
          value = @processor_info[key]
          if value.is_a?(Numeric) && value <= 0
            @processor_info[key] = nil
          end
        end
      end

      def self.sysctl_value(name)
        # make sure to redirect stderr so we don't spew if the name is unknown
        `sysctl -n #{name} 2>/dev/null`.to_i
      end

      def self.parse_cpuinfo(cpuinfo)
        # Build a hash of the form
        #   { [phys_id, core_id] => num_logical_processors_on_this_core }
        cores = Hash.new(0)
        phys_id = core_id = nil

        total_processors = 0

        cpuinfo.split("\n").map(&:strip).each do |line|
          case line
          when /^processor\s*:/
            cores[[phys_id, core_id]] += 1 if phys_id && core_id
            phys_id = core_id = nil # reset these values
            total_processors += 1
          when /^physical id\s*:(.*)/
            phys_id = $1.strip.to_i
          when /^core id\s*:(.*)/
            core_id = $1.strip.to_i
          end
        end
        cores[[phys_id, core_id]] += 1 if phys_id && core_id

        num_physical_packages = cores.keys.map(&:first).uniq.size
        num_physical_cores = cores.size
        num_logical_processors = cores.values.sum

        if num_physical_cores == 0
          num_logical_processors = total_processors

          if total_processors == 0
            # Likely a malformed file.
            num_logical_processors = nil
          end

          if total_processors == 1
            # Some older, single-core processors might not list ids,
            # so we'll just mark them all 1.
            num_physical_packages = 1
            num_physical_cores = 1
          else
            # We have no way of knowing how many packages or cores
            # we have, even though we know how many processors there are.
            num_physical_packages = nil
            num_physical_cores = nil
          end
        end

        {
          :num_physical_packages => num_physical_packages,
          :num_physical_cores => num_physical_cores,
          :num_logical_processors => num_logical_processors
        }
      end

      def self.num_physical_packages; processor_info[:num_physical_packages] end

      def self.num_physical_cores; processor_info[:num_physical_cores] end

      def self.num_logical_processors; processor_info[:num_logical_processors] end

      def self.processor_arch
        RbConfig::CONFIG['target_cpu']
      end

      def self.os_version
        proc_try_read('/proc/version')
      end

      # When operating within a Docker container, attempt to obtain the
      # container id.
      #
      # First look for `/proc/self/mountinfo` to exist on disk to signify
      # cgroups v2. If that file exists, read it and expect it to contain one
      # or more "/docker/containers/<container_id>/" lines from which the
      # container id can be gleaned.
      #
      # Next look for `/proc/self/cgroup` to exist on disk to signify cgroup v1.
      # If that file exists, read it and parse the "cpu" group info in the hope
      # of finding a 64 character container id value.
      #
      # For non-cgroups based containers, use a `nil` value for the container
      # id without generating any warnings or errors.
      def self.docker_container_id
        return unless ruby_os_identifier.include?('linux')

        cgroupsv2_based_id = docker_container_id_for_cgroupsv2
        return cgroupsv2_based_id if cgroupsv2_based_id

        cgroup_info = proc_try_read('/proc/self/cgroup')
        return unless cgroup_info

        parse_docker_container_id(cgroup_info)
      end

      def self.docker_container_id_for_cgroupsv2
        mountinfo = proc_try_read('/proc/self/mountinfo')
        return unless mountinfo

        Regexp.last_match(1) if mountinfo =~ DOCKER_CGROUPS_V2_PATTERN
      end

      def self.parse_docker_container_id(cgroup_info)
        cpu_cgroup = parse_cgroup_ids(cgroup_info)['cpu']
        return unless cpu_cgroup

        container_id = case cpu_cgroup
        # docker native driver w/out systemd (fs)
        when /[0-9a-f]{64,}/
          if $&.length == 64
            $&
          else # container ID is too long
            ::NewRelic::Agent.logger.debug("Ignoring docker ID of invalid length: '#{cpu_cgroup}'")
            return
          end
        # docker native driver with systemd
        when '/' then nil
        # in a cgroup, but we don't recognize its format
        when /docker\/.*[^0-9a-f]/
          ::NewRelic::Agent.logger.debug("Cgroup indicates docker but container_id has invalid characters: '#{cpu_cgroup}'")
          return
        when /docker/
          ::NewRelic::Agent.logger.debug("Cgroup indicates docker but container_id unrecognized: '#{cpu_cgroup}'")
          ::NewRelic::Agent.increment_metric('Supportability/utilization/docker/error')
          return
        else
          ::NewRelic::Agent.logger.debug("Ignoring unrecognized cgroup ID format: '#{cpu_cgroup}'")
          return
        end

        if container_id && container_id.size != 64
          ::NewRelic::Agent.logger.debug("Found docker container_id with invalid length: #{container_id}")
          ::NewRelic::Agent.increment_metric('Supportability/utilization/docker/error')
          nil
        else
          container_id
        end
      end

      def self.parse_cgroup_ids(cgroup_info)
        cgroup_ids = {}

        cgroup_info.split("\n").each do |line|
          parts = line.split(':')
          next unless parts.size == 3

          _, subsystems, cgroup_id = parts
          subsystems = subsystems.split(',')
          subsystems.each do |subsystem|
            cgroup_ids[subsystem] = cgroup_id
          end
        end

        cgroup_ids
      end

      # A File.read against /(proc|sysfs)/* can hang with some older Linuxes.
      # See https://bugzilla.redhat.com/show_bug.cgi?id=604887, RUBY-736, and
      # https://github.com/opscode/ohai/commit/518d56a6cb7d021b47ed3d691ecf7fba7f74a6a7
      # for details on why we do it this way.
      def self.proc_try_read(path)
        return nil unless File.exist?(path)

        content = +''
        File.open(path) do |f|
          loop do
            begin
              content << f.read_nonblock(4096)
            rescue EOFError
              break
            rescue Errno::EWOULDBLOCK, Errno::EAGAIN
              content = nil
              break # don't select file handle, just give up
            end
          end
        end
        content
      end

      def self.ram_in_mib
        if darwin?
          (sysctl_value('hw.memsize') / (1024**2))
        elsif linux?
          meminfo = proc_try_read('/proc/meminfo')
          parse_linux_meminfo_in_mib(meminfo)
        elsif bsd?
          (sysctl_value('hw.realmem') / (1024**2))
        else
          ::NewRelic::Agent.logger.debug("Unable to determine ram_in_mib for host os: #{ruby_os_identifier}")
          nil
        end
      end

      def self.parse_linux_meminfo_in_mib(meminfo)
        if meminfo && mem_total = meminfo[/MemTotal:\s*(\d*)\skB/, 1]
          (mem_total.to_i / 1024).to_i
        else
          ::NewRelic::Agent.logger.debug("Failed to parse MemTotal from /proc/meminfo: #{meminfo}")
          nil
        end
      end

      def self.boot_id
        return nil unless linux?

        if bid = proc_try_read('/proc/sys/kernel/random/boot_id')
          bid.chomp!

          if bid.ascii_only?
            if bid.empty?
              ::NewRelic::Agent.logger.debug('boot_id not found in /proc/sys/kernel/random/boot_id')
              ::NewRelic::Agent.increment_metric('Supportability/utilization/boot_id/error')
              nil

            elsif bid.bytesize == 36
              bid

            else
              ::NewRelic::Agent.logger.debug("Found boot_id with invalid length: #{bid}")
              ::NewRelic::Agent.increment_metric('Supportability/utilization/boot_id/error')
              bid[0, 128]

            end
          else
            ::NewRelic::Agent.logger.debug("Found boot_id with non-ASCII characters: #{bid}")
            ::NewRelic::Agent.increment_metric('Supportability/utilization/boot_id/error')
            nil

          end
        else
          ::NewRelic::Agent.logger.debug('boot_id not found in /proc/sys/kernel/random/boot_id')
          ::NewRelic::Agent.increment_metric('Supportability/utilization/boot_id/error')
          nil

        end
      end
    end
  end
end
