# Copyright 2017 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


require "faraday"
require "json"


module Google
  module Cloud
    ##
    # # Google Cloud hosting environment
    #
    # This library provides access to information about the application's
    # hosting environment if it is running on Google Cloud Platform. You may
    # use this library to determine which Google Cloud product is hosting your
    # application (e.g. App Engine, Kubernetes Engine), information about the
    # Google Cloud project hosting the application, information about the
    # virtual machine instance, authentication information, and so forth.
    #
    # ## Usage
    #
    # Obtain an instance of the environment info with:
    #
    # ```ruby
    # require "google/cloud/env"
    # env = Google::Cloud.env
    # ```
    #
    # Then you can interrogate any fields using methods on the object.
    #
    # ```ruby
    # if env.app_engine?
    #   # App engine specific logic
    # end
    # ```
    #
    # Any item that does not apply to the current environment will return nil.
    # For example:
    #
    # ```ruby
    # unless env.app_engine?
    #   service = env.app_engine_service_id  # => nil
    # end
    # ```
    #
    class Env
      # @private Base (host) URL for the metadata server.
      METADATA_HOST = "http://169.254.169.254".freeze

      # @private URL path for v1 of the metadata service.
      METADATA_PATH_BASE = "/computeMetadata/v1".freeze

      # @private URL path for metadata server root.
      METADATA_ROOT_PATH = "/".freeze

      # @private
      METADATA_FAILURE_EXCEPTIONS = [
        Faraday::TimeoutError,
        Faraday::ConnectionFailed,
        Errno::EHOSTDOWN,
        Errno::ETIMEDOUT,
        Timeout::Error
      ].freeze

      ##
      # Create a new instance of the environment information.
      # Most client should not need to call this directly. Obtain a singleton
      # instance of the information from `Google::Cloud.env`. This constructor
      # is provided to allow customization of the timeout/retry settings, as
      # well as mocking for testing.
      #
      # @param [Hash] env Mock environment variables.
      # @param [Hash,false] metadata_cache The metadata cache. You may pass
      #     a prepopuated cache, an empty cache (the default) or `false` to
      #     disable the cache completely.
      # @param [Numeric] open_timeout Timeout for opening http connections.
      #     Defaults to 0.1.
      # @param [Numeric] request_timeout Timeout for entire http requests.
      #     Defaults to 1.0.
      # @param [Integer] retry_count Number of times to retry http requests.
      #     Defaults to 1. Note that retry remains in effect even if a custom
      #     `connection` is provided.
      # @param [Numeric] retry_interval Time between retries in seconds.
      #     Defaults to 0.1.
      # @param [Numeric] retry_backoff_factor Multiplier applied to the retry
      #     interval on each retry. Defaults to 1.5.
      # @param [Numeric] retry_max_interval Maximum time between retries in
      #     seconds. Defaults to 0.5.
      # @param [Faraday::Connection] connection Faraday connection to use.
      #     If specified, overrides the `request_timeout` and `open_timeout`
      #     settings.
      #
      def initialize env: nil, connection: nil, metadata_cache: nil,
                     open_timeout: 0.1, request_timeout: 1.0,
                     retry_count: 2, retry_interval: 0.1,
                     retry_backoff_factor: 1.5, retry_max_interval: 0.5
        @disable_metadata_cache = metadata_cache == false
        @metadata_cache = metadata_cache || {}
        @env = env || ::ENV
        @retry_count = retry_count
        @retry_interval = retry_interval
        @retry_backoff_factor = retry_backoff_factor
        @retry_max_interval = retry_max_interval
        request_opts = { timeout: request_timeout, open_timeout: open_timeout }
        @connection = connection ||
                      ::Faraday.new(url: METADATA_HOST, request: request_opts)
      end

      ##
      # Determine whether the application is running on Google App Engine.
      #
      # @return [Boolean]
      #
      def app_engine?
        env["GAE_INSTANCE"] ? true : false
      end

      ##
      # Determine whether the application is running on Google Kubernetes
      # Engine (GKE).
      #
      # @return [Boolean]
      #
      def kubernetes_engine?
        kubernetes_engine_cluster_name ? true : false
      end
      alias container_engine? kubernetes_engine?

      ##
      # Determine whether the application is running on Google Cloud Shell.
      #
      # @return [Boolean]
      #
      def cloud_shell?
        env["DEVSHELL_GCLOUD_CONFIG"] ? true : false
      end

      ##
      # Determine whether the application is running on Google Compute Engine.
      #
      # Note that most other products (e.g. App Engine, Kubernetes Engine,
      # Cloud Shell) themselves use Compute Engine under the hood, so this
      # method will return true for all the above products. If you want to
      # determine whether the application is running on a "raw" Compute Engine
      # VM without using a higher level hosting product, use
      # {Env#raw_compute_engine?}.
      #
      # @return [Boolean]
      #
      def compute_engine?
        metadata?
      end

      ##
      # Determine whether the application is running on "raw" Google Compute
      # Engine without using a higher level hosting product such as App
      # Engine or Kubernetes Engine.
      #
      # @return [Boolean]
      #
      def raw_compute_engine?
        !app_engine? && !cloud_shell? && metadata? && !kubernetes_engine?
      end

      ##
      # Returns the unique string ID of the project hosting the application,
      # or `nil` if the application is not running on Google Cloud.
      #
      # @return [String,nil]
      #
      def project_id
        env["GCLOUD_PROJECT"] || env["DEVSHELL_PROJECT_ID"] ||
          lookup_metadata("project", "project-id")
      end

      ##
      # Returns the unique numeric ID of the project hosting the application,
      # or `nil` if the application is not running on Google Cloud.
      #
      # Caveat: this method does not work and returns `nil` on CloudShell.
      #
      # @return [Integer,nil]
      #
      def numeric_project_id
        # CloudShell's metadata server seems to run in a dummy project.
        # We can get the user's normal project ID via environment variables,
        # but the numeric ID from the metadata service is not correct. So
        # disable this for CloudShell to avoid confusion.
        return nil if cloud_shell?

        result = lookup_metadata "project", "numeric-project-id"
        result.nil? ? nil : result.to_i
      end

      ##
      # Returns the name of the VM instance hosting the application, or `nil`
      # if the application is not running on Google Cloud.
      #
      # @return [String,nil]
      #
      def instance_name
        env["GAE_INSTANCE"] || lookup_metadata("instance", "name")
      end

      ##
      # Returns the description field (which may be the empty string) of the
      # VM instance hosting the application, or `nil` if the application is
      # not running on Google Cloud.
      #
      # @return [String,nil]
      #
      def instance_description
        lookup_metadata "instance", "description"
      end

      ##
      # Returns the zone (for example "`us-central1-c`") in which the instance
      # hosting the application lives. Returns `nil` if the application is
      # not running on Google Cloud.
      #
      # @return [String,nil]
      #
      def instance_zone
        result = lookup_metadata "instance", "zone"
        result.nil? ? nil : result.split("/").last
      end

      ##
      # Returns the machine type of the VM instance hosting the application,
      # or `nil` if the application is not running on Google Cloud.
      #
      # @return [String,nil]
      #
      def instance_machine_type
        result = lookup_metadata "instance", "machine-type"
        result.nil? ? nil : result.split("/").last
      end

      ##
      # Returns an array (which may be empty) of all tags set on the VM
      # instance hosting the  application, or `nil` if the application is not
      # running on Google Cloud.
      #
      # @return [Array<String>,nil]
      #
      def instance_tags
        result = lookup_metadata "instance", "tags"
        result.nil? ? nil : JSON.parse(result)
      end

      ##
      # Returns an array (which may be empty) of all attribute keys present
      # for the VM instance hosting the  application, or `nil` if the
      # application is not running on Google Cloud.
      #
      # @return [Array<String>,nil]
      #
      def instance_attribute_keys
        result = lookup_metadata "instance", "attributes/"
        result.nil? ? nil : result.split
      end

      ##
      # Returns the value of the given instance attribute for the VM instance
      # hosting the application, or `nil` if the given key does not exist or
      # application is not running on Google Cloud.
      #
      # @param [String] key Attribute key to look up.
      # @return [String,nil]
      #
      def instance_attribute key
        lookup_metadata "instance", "attributes/#{key}"
      end

      ##
      # Returns the name of the running App Engine service, or `nil` if the
      # current code is not running in App Engine.
      #
      # @return [String,nil]
      #
      def app_engine_service_id
        env["GAE_SERVICE"]
      end

      ##
      # Returns the version of the running App Engine service, or `nil` if the
      # current code is not running in App Engine.
      #
      # @return [String,nil]
      #
      def app_engine_service_version
        env["GAE_VERSION"]
      end

      ##
      # Returns the amount of memory reserved for the current App Engine
      # instance, or `nil` if the current code is not running in App Engine.
      #
      # @return [Integer,nil]
      #
      def app_engine_memory_mb
        result = env["GAE_MEMORY_MB"]
        result.nil? ? nil : result.to_i
      end

      ##
      # Returns the name of the Kubernetes Engine cluster hosting the
      # application, or `nil` if the current code is not running in
      # Kubernetes Engine.
      #
      # @return [String,nil]
      #
      def kubernetes_engine_cluster_name
        instance_attribute "cluster-name"
      end
      alias container_engine_cluster_name kubernetes_engine_cluster_name

      ##
      # Returns the name of the Kubernetes Engine namespace hosting the
      # application, or `nil` if the current code is not running in
      # Kubernetes Engine.
      #
      # @return [String,nil]
      #
      def kubernetes_engine_namespace_id
        # The Kubernetes namespace is difficult to obtain without help from
        # the application using the Downward API. The environment variable
        # below is set in some older versions of GKE, and the file below is
        # present in Kubernetes as of version 1.9, but it is possible that
        # alternatives will need to be found in the future.
        env["GKE_NAMESPACE_ID"] ||
          ::IO.read("/var/run/secrets/kubernetes.io/serviceaccount/namespace")
      rescue SystemCallError
        nil
      end
      alias container_engine_namespace_id kubernetes_engine_namespace_id

      ##
      # Determine whether the Google Compute Engine Metadata Service is running.
      #
      # @return [Boolean]
      #
      def metadata?
        path = METADATA_ROOT_PATH
        if @disable_metadata_cache || !metadata_cache.include?(path)
          metadata_cache[path] = retry_or_fail_with false do
            resp = connection.get path
            resp.status == 200 && resp.headers["Metadata-Flavor"] == "Google"
          end
        end
        metadata_cache[path]
      end

      ##
      # Retrieve info from the Google Compute Engine Metadata Service.
      # Returns `nil` if the Metadata Service is not running or the given
      # data is not present.
      #
      # @param [String] type Type of metadata to look up. Currently supported
      #     values are "project" and "instance".
      # @param [String] entry Metadata entry path to look up.
      # @return [String,nil]
      #
      def lookup_metadata type, entry
        return nil unless metadata?

        path = "#{METADATA_PATH_BASE}/#{type}/#{entry}"
        if @disable_metadata_cache || !metadata_cache.include?(path)
          metadata_cache[path] = retry_or_fail_with nil do
            resp = connection.get path do |req|
              req.headers = { "Metadata-Flavor" => "Google" }
            end
            resp.status == 200 ? resp.body.strip : nil
          end
        end
        metadata_cache[path]
      end

      ##
      # Returns the global instance of {Google::Cloud::Env}.
      #
      # @return [Google::Cloud::Env]
      #
      def self.get
        ::Google::Cloud.env
      end

      private

      attr_reader :connection
      attr_reader :env
      attr_reader :metadata_cache

      def retry_or_fail_with error_result
        retries_remaining = @retry_count
        retry_interval = @retry_interval
        begin
          yield
        rescue *METADATA_FAILURE_EXCEPTIONS
          retries_remaining -= 1
          if retries_remaining >= 0
            sleep retry_interval
            retry_interval *= @retry_backoff_factor
            if retry_interval > @retry_max_interval
              retry_interval = @retry_max_interval
            end
            retry
          end
          error_result
        end
      end
    end

    @env = Env.new

    ##
    # Returns the global instance of {Google::Cloud::Env}.
    #
    # @return [Google::Cloud::Env]
    #
    def self.env
      @env
    end
  end
end
