# frozen_string_literal: true

# Copyright 2021 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
#
#     http://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 "date"
require "erb"
require "json"
require "net/http"
require "securerandom"
require "shellwords"
require "tempfile"
require "yaml"

require "google/serverless/exec/gcloud"

module Google
  module Serverless
    ##
    # # Serverless execution tool
    #
    # This class provides a client for serverless execution, allowing
    # Serverless applications to perform on-demand tasks in the serverless
    # environment. This may be used for safe running of ops and maintenance
    # tasks, such as database migrations, that access production cloud resources.
    #
    # ## About serverless execution tool
    #
    # Serverless execution spins up a one-off copy of a serverless app, and runs
    # a command against it. For example, if your app runs on Ruby on Rails, then
    # you might use serverless execution to run a command such as
    # `bundle exec bin/rails db:migrate` in production infrastructure (to avoid
    # having to connect directly to your production database from a local
    # workstation).
    #
    # Serverless execution provides two strategies for generating that "one-off
    # copy":
    #
    #  *  A `deployment` strategy, which deploys a temporary version of your app
    #     to a single backend instance and runs the command there.
    #  *  A `cloud_build` strategy, which deploys your application image to
    #     Google Cloud Build and runs the command there.
    #
    # Both strategies are generally designed to emulate the runtime
    # environment on cloud VMs similar to those used by actual deployments of
    # your app. Both provide your application code and environment variables, and
    # both provide access to Cloud SQL connections used by your app. However,
    # they differ in what *version* of your app code they run against, and in
    # certain other constraints and performance characteristics. More detailed
    # information on using the two strategies is provided in the sections below.
    #
    # Apps deployed to the App Engine *flexible environment* and Cloud Run will use the
    # `cloud_build` strategy by default. However, you can force an app to use the
    # `deployment` strategy instead. (You might do so if you need to connect to a
    # Cloud SQL database on a VPC using a private IP, because the `cloud_build`
    # strategy does not support private IPs.) To force use of `deployment`, set
    # the `strategy` parameter in the {Google::Serverless::Exec} constructor (or the
    # corresponding `GAE_EXEC_STRATEGY` parameter in the Rake task). Note that
    # the `deployment` strategy is usually significantly slower than
    # `cloud_build` for apps in the flexible environment.
    #
    # Apps deployed to the App Engine *standard environment* will *always* use
    # the `deployment` strategy. You cannot force use of the `cloud_build`
    # strategy.
    #
    # ## Prerequisites
    #
    # To use this tool, you will need:
    #
    # * An app deployed to Google serverless compute, of course!
    # * The [gcloud SDK](https://cloud.google.com/sdk/) installed and configured.
    # * The `serverless` gem.
    #
    # You may use the `Google::Serverless::Exec` class to run commands directly. However,
    # in most cases, it will be easier to run commands via the provided rake
    # tasks (see {Google::Serverless::Tasks}).
    #
    # ## Using the "deployment" strategy
    #
    # The `deployment` strategy deploys a temporary version of your app to a
    # single backend App Engine instance, runs the command there, and then
    # deletes the temporary version when it is finished.
    #
    # This is the default strategy (and indeed the only option) for apps running
    # on the App Engine standard environment. It can also be used for flexible
    # environment apps, but this is not commonly done because deployment of
    # flexible environment apps can take a long time.
    #
    # Because the `deployment` strategy deploys a temporary version of your app,
    # it runs against the *current application code* present where the command
    # was initiated (i.e. the code currently on your workstation if you run the
    # rake task from your workstation, or the current code on the branch if you
    # are running from a CI/CD system.) This may be different from the code
    # actually running in production, so it is important that you run from a
    # compatible code branch.
    #
    # ### Specifying the host application
    #
    # The `deployment` strategy works by deploying a temporary version of your
    # app, so that it has access to your app's project and settings in App
    # Engine. In most cases, it can determine this automatically, but depending
    # on how your app or environment is structured, you may need to give it some
    # help.
    #
    # By default, your Google Cloud project is taken from the current gcloud
    # project. If you need to override this, set the `:project` parameter in the
    # {Google::Serverless::Exec} constructor (or the corresponding `GAE_PROJECT`
    # parameter in the Rake task).
    #
    # By default, the service name is taken from the App Engine config file.
    # Serverless execution will assume this file is called `app.yaml` in the
    # current directory. To use a different config file, set the `config_path`
    # parameter in the {Google::Serverless::Exec} constructor (or the corresponding
    # `GAE_CONFIG` parameter in the Rake task). You may also set the service name
    # directly, using the `service` parameter (or `GAE_SERVICE` in Rake).
    #
    # ### Providing credentials
    #
    # Your command will effectively be a deployment of your serverless app
    # itself, and will have access to the same credentials. For example, App
    # Engine provides a service account by default for your app, or your app may
    # be making use of its own service account key. In either case, make sure the
    # service account has sufficient access for the command you want to run
    # (such as database admin credentials).
    #
    # ### Other options
    #
    # You may also provide a timeout, which is the length of time that serverless
    # execution will allow your command to run before it is considered to
    # have stalled and is terminated. The timeout should be a string of the form
    # `2h15m10s`. The default is `10m`.
    #
    # The timeout is set via the `timeout` parameter to the {Google::Serverless::Exec}
    # constructor, or by setting the `GAE_TIMEOUT` environment variable when
    # invoking using Rake.
    #
    # ### Resource usage and billing
    #
    # The `deployment` strategy deploys to a temporary instance of your app in
    # order to run the command. You may be billed for that usage. However, the
    # cost should be minimal, because it will then immediately delete that
    # instance in order to minimize usage.
    #
    # If you interrupt the execution (or it crashes), it is possible that the
    # temporary instance may not get deleted properly. If you suspect this may
    # have happened, go to the App Engine tab in the cloud console, under
    # "versions" of your service, and delete the temporary version manually. It
    # will have a name matching the pattern `serverless-exec-<timestamp>`.
    #
    # ## Using the "cloud_build" strategy
    #
    # The `cloud_build` strategy takes the application image that App Engine or Cloud Run is
    # actually using to run your app, and uses it to spin up a copy of your app
    # in [Google Cloud Build](https://cloud.google.com/cloud-build) (along with
    # an emulation layer that emulates certain serverless services such as Cloud
    # SQL connection sockets). The command then gets run in the Cloud Build
    # environment.
    #
    # This is the default strategy for apps running on the App Engine flexible and Cloud
    # Run environments. (It is not available for standard environment apps.) Note that
    # the `cloud_build` strategy cannot be used if your command needs to connect
    # to a database over a [VPC](https://cloud.google.com/vpc/) private IP
    # address. This is because it runs on virtual machines provided by the Cloud
    # Build service, which are not part of your VPC. If your database can be
    # accessed only over a private IP, you should use the `deployment` strategy
    # instead.
    #
    # The Cloud Build log is output to the directory specified by
    # "CLOUD_BUILD_GCS_LOG_DIR". (ex. "gs://BUCKET-NAME/FOLDER-NAME")
    # By default, log directory name is
    # "gs://[PROJECT_NUMBER].cloudbuild-logs.googleusercontent.com/".
    #
    # ### Specifying the host application
    #
    # The `cloud_build` strategy needs to know exactly which app, service, and
    # version of your app, to identify the application image to use.
    #
    # By default, your Google Cloud project is taken from the current gcloud
    # project. If you need to override this, set the `:project` parameter in the
    # {Google::Serverless::Exec} constructor (or the corresponding `GAE_PROJECT`
    # parameter in the Rake task).
    #
    # By default, the service name is taken from the App Engine config file.
    # Serverless execution will assume this file is called `app.yaml` in the
    # current directory. To use a different config file, set the `config_path`
    # parameter in the {Google::Serverless::Exec} constructor (or the corresponding
    # `GAE_CONFIG` parameter in the Rake task). You may also set the service name
    # directly, using the `service` parameter (or `GAE_SERVICE` in Rake).
    #
    # By default, the image of the most recently deployed version of your app is
    # used. (Note that this most recently deployed version may not be the same
    # version that is currently receiving traffic: for example, if you deployed
    # with `--no-promote`.) To use a different version, set the `version`
    # parameter in the {Google::Serverless::Exec} constructor
    # (or the corresponding `GAE_VERSION` parameter in the Rake task).
    #
    # ### Providing credentials
    #
    # By default, the `cloud_build` strategy uses your project's Cloud Build
    # service account for its credentials. Unless your command provides its own
    # service account key, you may need to grant the Cloud Build service account
    # any permissions needed to execute your command (such as access to your
    # database). For most tasks, it is sufficient to grant Project Editor
    # permissions to the service account. You can find the service account
    # configuration in the IAM tab in the Cloud Console under the name
    # `[your-project-number]@cloudbuild.gserviceaccount.com`.
    #
    # ### Other options
    #
    # You may also provide a timeout, which is the length of time that
    # serverless execution will allow your command to run before it is considered to
    # have stalled and is terminated. The timeout should be a string of the form
    # `2h15m10s`. The default is `10m`.
    #
    # The timeout is set via the `timeout` parameter to the {Google::Serverless::Exec}
    # constructor, or by setting the `GAE_TIMEOUT` environment variable when
    # invoking using Rake.
    #
    # You can also set the wrapper image used to emulate the App Engine runtime
    # environment, by setting the `wrapper_image` parameter to the constructor,
    # or by setting the `GAE_EXEC_WRAPPER_IMAGE` environment variable. Generally,
    # you will not need to do this unless you are testing a new wrapper image.
    #
    # ### Resource usage and billing
    #
    # The `cloud_build` strategy uses virtual machine resources provided by
    # Google Cloud Build. Generally, a certain number of usage minutes per day is
    # covered under a free tier, but additional compute usage beyond that time is
    # billed to your Google Cloud account. For more details,
    # see https://cloud.google.com/cloud-build/pricing
    #
    # If your command makes API calls or utilizes other cloud resources, you may
    # also be billed for that usage. However, the `cloud_build` strategy (unlike
    # the `deployment` strategy) does not use actual App Engine instances, and
    # you will not be billed for additional App Engine instance usage.
    #
    class Exec
      @default_timeout = "10m"
      @default_service = "default"
      @default_config_path = "./app.yaml"
      @default_wrapper_image = "gcr.io/google-appengine/exec-wrapper:latest"

      APP_ENGINE = :app_engine
      CLOUD_RUN = :cloud_run

      ##
      # Base class for exec-related usage errors.
      #
      class UsageError < ::StandardError
      end

      ##
      # Unsupported strategy
      #
      class UnsupportedStrategy < UsageError
        def initialize strategy, app_env
          @strategy = strategy
          @app_env = app_env
          super "Strategy \"#{strategy}\" not supported for the #{app_env}" \
                " environment"
        end
        attr_reader :strategy
        attr_reader :app_env
      end

      ##
      # Exception raised when a parameter is malformed.
      #
      class BadParameter < UsageError
        def initialize param, value
          @param_name = param
          @value = value
          super "Bad value for #{param}: #{value}"
        end
        attr_reader :param_name
        attr_reader :value
      end

      ##
      # Exception raised when gcloud has no default project.
      #
      class NoDefaultProject < UsageError
        def initialize
          super "No default project set."
        end
      end

      ##
      # Exception raised when the App Engine config file could not be found.
      #
      class ConfigFileNotFound < UsageError
        def initialize config_path
          @config_path = config_path
          super "Config file #{config_path} not found."
        end
        attr_reader :config_path
      end

      ##
      # Exception raised when the App Engine config file could not be parsed.
      #
      class BadConfigFileFormat < UsageError
        def initialize config_path
          @config_path = config_path
          super "Config file #{config_path} malformed."
        end
        attr_reader :config_path
      end

      ##
      # Exception raised when the given version could not be found, or no
      # versions at all could be found for the given service.
      #
      class NoSuchVersion < UsageError
        def initialize service, version = nil
          @service = service
          @version = version
          if version
            super "No such version \"#{version}\" for service \"#{service}\""
          else
            super "No versions found for service \"#{service}\""
          end
        end
        attr_reader :service
        attr_reader :version
      end

      class << self
        ## @return [String] Default command timeout.
        attr_accessor :default_timeout

        ## @return [String] Default service name if the config doesn't specify.
        attr_accessor :default_service

        ## @return [String] Path to default config file.
        attr_accessor :default_config_path

        ## @return [String] Docker image that implements the app engine wrapper.
        attr_accessor :default_wrapper_image

        ##
        # Create an execution for a rake task.
        #
        # @param name [String] Name of the task
        # @param args [Array<String>] Args to pass to the task
        # @param env_args [Array<String>] Environment variable settings, each
        #     of the form `NAME=value`.
        # @param service [String,nil] Name of the service. If omitted, obtains
        #     the service name from the config file.
        # @param config_path [String,nil] App Engine config file to get the
        #     service name from if the service name is not provided directly.
        #     If omitted, defaults to the value returned by
        #     {Google::Serverless::Exec.default_config_path}.
        # @param version [String,nil] Version string. If omitted, defaults to the
        #     most recently created version of the given service (which may not
        #     be the one currently receiving traffic).
        # @param timeout [String,nil] Timeout string. If omitted, defaults to the
        #     value returned by {Google::Serverless::Exec.default_timeout}.
        # @param wrapper_image [String,nil] The fully qualified name of the
        #     wrapper image to use. (Applies only to the "cloud_build" strategy.)
        # @param strategy [String,nil] The execution strategy to use, or `nil` to
        #     choose a default based on the App Engine (flexible or standard) or Cloud  Run
        #     environments. Allowed values are `nil`, `"deployment"` (which is the
        #     default for App Engine Standard), and `"cloud_build"` (which is the default
        #     for App Engine Flexible and Cloud Run).
        # @param gcs_log_dir [String,nil] GCS bucket name of the cloud build log
        #     when strategy is "cloud_build". (ex. "gs://BUCKET-NAME/FOLDER-NAME")
        # @param product [Symbol] The serverless product to use. If omitted, defaults to
        #     the value returned by {Google::Serverless::Exec#default_product}
        def new_rake_task name, args: [], env_args: [],
                          service: nil, config_path: nil, version: nil,
                          timeout: nil, project: nil, wrapper_image: nil,
                          strategy: nil, gcs_log_dir: nil, product: nil
          escaped_args = args.map do |arg|
            arg.gsub(/[,\[\]]/) { |m| "\\#{m}" }
          end
          name_with_args =
            if escaped_args.empty?
              name
            else
              "#{name}[#{escaped_args.join ','}]"
            end
          new ["bundle", "exec", "rake", name_with_args] + env_args,
              service: service, config_path: config_path, version: version,
              timeout: timeout, project: project, wrapper_image: wrapper_image,
              strategy: strategy, gcs_log_dir: gcs_log_dir, product: product
        end
      end

      ##
      # Create an execution for the given command.
      #
      # @param command [Array<String>] The command in array form.
      # @param project [String,nil] ID of the project. If omitted, obtains
      #     the project from gcloud.
      # @param service [String,nil] Name of the service. If omitted, obtains
      #     the service name from the config file.
      # @param config_path [String,nil] App Engine config file to get the
      #     service name from if the service name is not provided directly.
      #     If omitted, defaults to the value returned by
      #     {Google::Serverless::Exec.default_config_path}.
      # @param version [String,nil] Version string. If omitted, defaults to the
      #     most recently created version of the given service (which may not be
      #     the one currently receiving traffic).
      # @param timeout [String,nil] Timeout string. If omitted, defaults to the
      #     value returned by {Google::Serverless::Exec.default_timeout}.
      # @param wrapper_image [String,nil] The fully qualified name of the wrapper
      #     image to use. (Applies only to the "cloud_build" strategy.)
      # @param strategy [String,nil] The execution strategy to use, or `nil` to
      #     choose a default based on the App Engine environment (flexible or standard) or
      #     Cloud Run environments. Allowed values are `nil`, `"deployment"` (which is the
      #     default for App Engine Standard), and `"cloud_build"` (which is the default for
      #     App Engine Flexible and Cloud Run).
      # @param gcs_log_dir [String,nil] GCS bucket name of the cloud build log
      #     when strategy is "cloud_build". (ex. "gs://BUCKET-NAME/FOLDER-NAME")
      # @param product [Symbol] The serverless product. If omitted, defaults to the
      #     value returns by {Google::Serverless::Exec#default_product}.
      #     Allowed values are {APP_ENGINE} and {CLOUD_RUN}.
      # @param region [String] The region for the cloud run service. Required
      #     if the product is cloud run.
      #
      def initialize command,
                     project: nil,
                     service: nil,
                     config_path: nil,
                     version: nil,
                     timeout: nil,
                     wrapper_image: nil,
                     strategy: nil,
                     gcs_log_dir: nil,
                     product: nil,
                     region: nil
        @command = command
        @service = service
        @config_path = config_path
        @version = version
        @timeout = timeout
        @project = project
        @wrapper_image = wrapper_image
        @strategy = strategy
        @gcs_log_dir = gcs_log_dir
        @product = product
        @region = region

        yield self if block_given?
      end

      ##
      # @return [String] The project ID.
      # @return [nil] if the default gcloud project should be used.
      #
      attr_accessor :project

      ##
      # @return [String] The service name.
      # @return [nil] if the service should be obtained from the app config.
      #
      attr_accessor :service

      ##
      # @return [String] Path to the config file.
      # @return [nil] if the default of `./app.yaml` should be used.
      #
      attr_accessor :config_path

      ##
      # @return [String] Service version of the image to use.
      # @return [nil] if the most recent should be used.
      #
      attr_accessor :version

      ##
      # @return [String] The command timeout, in `1h23m45s` format.
      # @return [nil] if the default of `10m` should be used.
      #
      attr_accessor :timeout

      ##
      # The command to run.
      #
      # @return [String] if the command is a script to be run in a shell.
      # @return [Array<String>] if the command is a posix command to be run
      #     directly without a shell.
      #
      attr_accessor :command

      ##
      # @return [String] Custom wrapper image to use.
      # @return [nil] if the default should be used.
      #
      attr_accessor :wrapper_image

      ##
      # @return [String] The execution strategy to use. Allowed values are
      #     `"deployment"` and `"cloud_build"`.
      # @return [nil] to choose a default based on the App Engine (flexible or standard)
      #     or Cloud Run environments.
      #
      attr_accessor :strategy

      ##
      # @return [Symbol] The serverless product to use.
      #     Allowed values are {APP_ENGINE} and {CLOUD_RUN}
      #
      attr_accessor :product

      ##
      # @return [String] The region for the cloud run service
      #
      attr_accessor :region

      ##
      # Executes the command synchronously. Streams the logs back to standard out
      # and does not return until the command has completed or timed out.

      def start
        resolve_parameters
        case @product
        when APP_ENGINE
          start_app_engine
        when CLOUD_RUN
          start_cloud_run
        end
      end

      def start_app_engine
        app_info = version_info @service, @version
        resolve_strategy app_info["env"]
        if @strategy == "cloud_build"
          start_build_strategy app_info
        else
          start_deployment_strategy app_info
        end
      end

      def start_cloud_run
        app_info = version_info_cloud_run @service
        start_build_strategy app_info
      end

      private

      ##
      # @private
      # Resolves and canonicalizes all the parameters.
      #
      def resolve_parameters
        @timestamp_suffix = ::Time.now.strftime "%Y%m%d%H%M%S"
        @command = ::Shellwords.split @command.to_s unless @command.is_a? Array
        @project ||= default_project
        @service ||= service_from_config || Exec.default_service
        @timeout ||= Exec.default_timeout
        @timeout_seconds = parse_timeout @timeout
        @wrapper_image ||= Exec.default_wrapper_image
        @product ||= default_product
        if @product == APP_ENGINE
          @version ||= latest_version @service
        end
        self
      end

      def resolve_strategy app_env
        @strategy = @strategy.to_s.downcase
        if @strategy.empty?
          @strategy = app_env == "flexible" ? "cloud_build" : "deployment"
        end
        if app_env == "standard" && @strategy == "cloud_build" ||
           @strategy != "cloud_build" && @strategy != "deployment"
          raise UnsupportedStrategy.new @strategy, app_env
        end
        @strategy
      end

      def service_from_config
        return nil if !@config_path && @service
        @config_path ||= Exec.default_config_path
        ::YAML.load_file(config_path)["service"]
      rescue ::Errno::ENOENT
        raise ConfigFileNotFound, @config_path
      rescue ::StandardError
        raise BadConfigFileFormat, @config_path
      end

      def default_project
        result = Exec::Gcloud.execute \
          ["config", "get-value", "project"],
          capture: true, assert: false
        result.strip!
        raise NoDefaultProject if result.empty?
        result
      end

      def default_product
        File.file?("app.yaml") ? APP_ENGINE : CLOUD_RUN
      end

      def parse_timeout timeout_str
        matched = timeout_str =~ /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?$/
        raise BadParameter.new "timeout", timeout_str unless matched
        hours = ::Regexp.last_match(1).to_i
        minutes = ::Regexp.last_match(2).to_i
        seconds = ::Regexp.last_match(3).to_i
        hours * 3600 + minutes * 60 + seconds
      end

      ##
      # @private
      # Returns the name of the most recently created version of the given
      # service.
      #
      # @param service [String] Name of the service.
      # @return [String] Name of the most recent version.
      #
      def latest_version service
        result = Exec::Gcloud.execute \
          [
            "app", "versions", "list",
            "--project", @project,
            "--service", service,
            "--format", "get(version.id)",
            "--sort-by", "~version.createTime",
            "--limit", "1"
          ],
          capture: true, assert: false
        result = result.split.first
        raise NoSuchVersion, service unless result
        result
      end

      ##
      # @private
      # Returns full information on the given version of the given service.
      #
      # @param service [String] Name of the service. If omitted, the service
      #     "default" is used.
      # @param version [String] Name of the version. If omitted, the most
      #     recently deployed is used.
      # @return [Hash] A collection of fields parsed from the JSON representation
      #     of the version
      # @return [nil] if the requested version doesn't exist.
      #
      def version_info service, version
        service ||= "default"
        version ||= latest_version service
        result = Exec::Gcloud.execute \
          [
            "app", "versions", "describe", version,
            "--project", @project,
            "--service", service,
            "--format", "json"
          ],
          capture: true, assert: false
        result.strip!
        raise NoSuchVersion.new(service, version) if result.empty?
        ::JSON.parse result
      end

      def version_info_cloud_run service
        service ||= "default"
        raise BadParameter.new("region", "(value missing)") unless region
        result = Exec::Gcloud.execute \
          [
            "run", "services", "describe", service,
            "--project", @project,
            "--region", region,
            "--format", "json"
          ],
          capture: true, assert: false, echo: true
        result.strip!
        ::JSON.parse result
      end

      ##
      # @private
      # Performs exec on a GAE standard app.
      #
      def start_deployment_strategy app_info
        describe_deployment_strategy
        entrypoint_file = app_yaml_file = temp_version = nil
        begin
          puts "\n---------- DEPLOY COMMAND ----------"
          secret = create_secret
          entrypoint_file = copy_entrypoint secret
          app_yaml_file = copy_app_yaml app_info, entrypoint_file
          temp_version = deploy_temp_app app_yaml_file
          puts "\n---------- EXECUTE COMMAND ----------"
          puts "COMMAND: #{@command.inspect}\n\n"
          exit_status = track_status temp_version, secret
          puts "\nEXIT STATUS: #{exit_status}"
        ensure
          puts "\n---------- CLEANUP ----------"
          ::File.unlink entrypoint_file if entrypoint_file
          ::File.unlink app_yaml_file if app_yaml_file
          delete_temp_version temp_version
        end
      end

      def describe_deployment_strategy
        puts "\nUsing the `deployment` strategy for serverless:exec"
        puts "(i.e. deploying a temporary version of your app)"
        puts "PROJECT: #{@project}"
        puts "SERVICE: #{@service}"
        puts "TIMEOUT: #{@timeout}"
      end

      def create_secret
        ::SecureRandom.alphanumeric 20
      end

      def copy_entrypoint secret
        base_dir = ::File.dirname ::File.dirname ::File.dirname __dir__
        entrypoint_template = ::File.join base_dir, "data", "exec_standard_entrypoint.rb.erb"
        entrypoint_file = "appengine_exec_entrypoint_#{@timestamp_suffix}.rb"
        erb = ::ERB.new ::File.read entrypoint_template
        data = {
          secret: secret.inspect, command: command.inspect
        }
        result = erb.result_with_hash data
        ::File.open entrypoint_file, "w" do |file|
          file.write result
        end
        entrypoint_file
      end

      def copy_app_yaml app_info, entrypoint_file
        yaml_data = {
          "runtime"        => app_info["runtime"],
          "service"        => @service,
          "entrypoint"     => "ruby #{entrypoint_file}",
          "env_variables"  => app_info["envVariables"],
          "manual_scaling" => { "instances" => 1 }
        }
        if app_info["env"] == "flexible"
          complete_flex_app_yaml yaml_data, app_info
        else
          complete_standard_app_yaml yaml_data, app_info
        end
        app_yaml_file = "appengine_exec_config_#{@timestamp_suffix}.yaml"
        ::File.open app_yaml_file, "w" do |file|
          ::Psych.dump yaml_data, file
        end
        app_yaml_file
      end

      def complete_flex_app_yaml yaml_data, app_info
        yaml_data["env"] = "flex"
        orig_path = (app_info["betaSettings"] || {})["module_yaml_path"]
        return unless orig_path
        orig_yaml = ::YAML.load_file orig_path
        copy_keys = ["skip_files", "resources", "network", "runtime_config",
                     "beta_settings"]
        copy_keys.each do |key|
          yaml_data[key] = orig_yaml[key] if orig_yaml[key]
        end
      end

      def complete_standard_app_yaml yaml_data, app_info
        yaml_data["instance_class"] = app_info["instanceClass"].sub(/^F/, "B")
      end

      def deploy_temp_app app_yaml_file
        temp_version = "appengine-exec-#{@timestamp_suffix}"
        Exec::Gcloud.execute [
          "app", "deploy", app_yaml_file,
          "--project", @project,
          "--version", temp_version,
          "--no-promote", "--quiet"
        ]
        temp_version
      end

      def track_status temp_version, secret
        host = "#{temp_version}.#{@service}.#{@project}.appspot.com"
        ::Net::HTTP.start host do |http|
          outpos = errpos = 0
          delay = 0.0
          loop do
            sleep delay
            uri = URI("http://#{host}/#{secret}")
            uri.query = ::URI.encode_www_form outpos: outpos, errpos: errpos
            response = http.request_get uri
            data = ::JSON.parse response.body
            data["outlines"].each { |line| puts "[STDOUT] #{line}" }
            data["errlines"].each { |line| puts "[STDERR] #{line}" }
            outpos = data["outpos"]
            errpos = data["errpos"]
            return data["status"] if data["status"]
            if data["time"] > @timeout_seconds
              http.request_post "/#{secret}/kill", ""
              return "timeout"
            end
            if data["outlines"].empty? && data["errlines"].empty?
              delay += 0.1
              delay = 1.0 if delay > 1.0
            else
              delay = 0.0
            end
          end
        end
      end

      def delete_temp_version temp_version
        Exec::Gcloud.execute [
          "app", "versions", "delete", temp_version,
          "--project", @project,
          "--service", @service,
          "--quiet"
        ]
      end

      ##
      # @private
      # Performs exec on a GAE flexible and Cloud Run apps.
      #
      def start_build_strategy app_info
        if @product == APP_ENGINE
          env_variables = app_info["envVariables"] || {}
          beta_settings = app_info["betaSettings"] || {}
          cloud_sql_instances = beta_settings["cloud_sql_instances"] || []
          container = app_info["deployment"]["container"]
          image = container ? container["image"] : image_from_build(app_info)
        else
          env_variables = {}
          app_env = app_info["spec"]["template"]["spec"]["containers"][0]["env"]
          app_env&.each { |env| env_variables[env["name"]] = env["value"] }
          metadata_annotations = app_info["spec"]["template"]["metadata"]["annotations"]
          cloud_sql_instances = metadata_annotations["run.googleapis.com/cloudsql-instances"] || []
          image = metadata_annotations["client.knative.dev/user-image"]
        end

        describe_build_strategy

        config = build_config command, image, env_variables, cloud_sql_instances
        file = ::Tempfile.new ["cloudbuild_", ".json"]
        begin
          ::JSON.dump config, file
          file.flush
          execute_command = [
            "builds", "submit",
            "--project", @project,
            "--no-source",
            "--config", file.path,
            "--timeout", @timeout
          ]
          execute_command.concat ["--gcs-log-dir", @gcs_log_dir] unless @gcs_log_dir.nil?
          Exec::Gcloud.execute execute_command
        ensure
          file.close!
        end
      end

      ##
      # @private
      # Workaround for https://github.com/GoogleCloudPlatform/appengine-ruby/issues/33
      # Determines the image by looking it up in Cloud Build
      #
      def image_from_build app_info
        create_time = ::DateTime.parse(app_info["createTime"]).to_time.utc
        after_time = (create_time - 3600).strftime "%Y-%m-%dT%H:%M:%SZ"
        before_time = (create_time + 3600).strftime "%Y-%m-%dT%H:%M:%SZ"
        partial_uri = "gcr.io/#{@project}/appengine/#{@service}.#{@version}"
        filter = "createTime>#{after_time} createTime<#{before_time} images[]:#{partial_uri}"
        result = Exec::Gcloud.execute \
          [
            "builds", "list",
            "--project", @project,
            "--filter", filter,
            "--format", "json"
          ],
          capture: true, assert: false
        result.strip!
        raise NoSuchVersion.new(@service, @version) if result.empty?
        build_info = ::JSON.parse(result).first
        build_info["images"].first
      end

      def describe_build_strategy
        puts "\nUsing the `cloud_build` strategy for serverless:exec"
        puts "(i.e. running your app image in Cloud Build)"
        puts "PROJECT: #{@project}"
        puts "SERVICE: #{@service}"
        puts "VERSION: #{@version}"
        puts "TIMEOUT: #{@timeout}"
        puts ""
      end

      ##
      # @private
      # Builds a cloudbuild config as a data structure.
      #
      # @param command [Array<String>] The command in array form.
      # @param image [String] The fully qualified image path.
      # @param env_variables [Hash<String,String>] Environment variables.
      # @param cloud_sql_instances [String,Array<String>] Names of cloud sql
      #     instances to connect to.
      #
      def build_config command, image, env_variables, cloud_sql_instances
        args = ["-i", image]
        env_variables.each do |k, v|
          v = v.gsub "$", "$$"
          args << "-e" << "#{k}=#{v}"
        end
        unless cloud_sql_instances.empty?
          cloud_sql_instances = Array(cloud_sql_instances)
          cloud_sql_instances.each do |sql|
            args << "-s" << sql
          end
        end
        args << "--"
        args += command

        {
          "steps" => [
            "name" => @wrapper_image,
            "args" => args
          ]
        }
      end
    end
  end
end
