# -*- coding: utf-8 -*- #
# Copyright 2021 Google LLC. All Rights Reserved.
#
# 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.

"""Implements the command for SSHing into an instance."""


import argparse
import datetime
import sys

from googlecloudsdk.api_lib.compute import base_classes
from googlecloudsdk.calliope import base
from googlecloudsdk.command_lib.compute import completers
from googlecloudsdk.command_lib.compute import flags
from googlecloudsdk.command_lib.compute import iap_tunnel
from googlecloudsdk.command_lib.compute import network_troubleshooter
from googlecloudsdk.command_lib.compute import scope as compute_scope
from googlecloudsdk.command_lib.compute import ssh_utils
from googlecloudsdk.command_lib.compute import user_permission_troubleshooter
from googlecloudsdk.command_lib.compute import vm_boot_troubleshooter
from googlecloudsdk.command_lib.compute import vm_status_troubleshooter
from googlecloudsdk.command_lib.compute import vpc_troubleshooter
from googlecloudsdk.command_lib.compute.instances import flags as instance_flags
from googlecloudsdk.command_lib.util.ssh import containers
from googlecloudsdk.command_lib.util.ssh import ssh
from googlecloudsdk.core import log
from googlecloudsdk.core import properties
from googlecloudsdk.core.util import retry

RECOMMEND_MESSAGE = """
Recommendation: To check for possible causes of SSH connectivity issues and get
recommendations, rerun the ssh command with the --troubleshoot option.

{0}
"""
_RECOMMEND_IAP = """
Or, to investigate an IAP tunneling issue:

{0}
"""

_RELEASE_TRACK = {
    'alpha': 'alpha',
    'beta': 'beta',
}

TROUBLESHOOT_HEADER = """
Starting ssh troubleshooting for instance {0} in zone {1}
Start time: {2}
"""


def AddCommandArg(parser):
  parser.add_argument(
      '--command',
      help="""\
      A command to run on the virtual machine.

      Runs the command on the target instance and then exits.
      """,
  )


def AddSSHArgs(parser):
  """Additional flags and positional args to be passed to *ssh(1)*."""
  parser.add_argument(
      '--ssh-flag',
      action='append',
      help="""\
      Additional flags to be passed to *ssh(1)*. It is recommended that flags
      be passed using an assignment operator and quotes. Example:

        $ {command} example-instance --zone=us-central1-a --ssh-flag="-vvv" --ssh-flag="-L 80:localhost:80"

      This flag will replace occurences of ``%USER%'', ``%INSTANCE%'', and
      ``%INTERNAL%'' with their dereferenced values. For example, passing
      ``80:%INSTANCE%:80'' into the flag is equivalent to passing
      ``80:162.222.181.197:80'' to *ssh(1)* if the external IP address of
      'example-instance' is 162.222.181.197.

      If connecting to the instance's external IP, then ``%INSTANCE%'' is
      replaced with that, otherwise it is replaced with the internal IP.
      ``%INTERNAL%'' is always replaced with the internal interface of the
      instance.
      """,
  )

  parser.add_argument(
      'user_host',
      completer=completers.InstancesCompleter,
      metavar='[USER@]INSTANCE',
      help="""\
      Specifies the instance to SSH into.

      ``USER'' specifies the username with which to SSH. If omitted,
      the user login name is used. If using OS Login, USER will be replaced
      by the OS Login user.

      ``INSTANCE'' specifies the name of the virtual machine instance to SSH
      into.
      """,
  )

  parser.add_argument(
      'ssh_args',
      nargs=argparse.REMAINDER,
      help="""\
          Flags and positionals passed to the underlying ssh implementation.
          """,
      example="""\
        $ {command} example-instance --zone=us-central1-a -- -vvv -L 80:%INSTANCE%:80
      """,
  )


def AddContainerArg(parser):
  parser.add_argument(
      '--container',
      help="""\
          The name or ID of a container inside of the virtual machine instance
          to connect to. This only applies to virtual machines that are using
          a Google Container-Optimized virtual machine image. For more
          information, see [](https://cloud.google.com/compute/docs/containers).
          """,
  )


def AddInternalIPArg(group):
  group.add_argument(
      '--internal-ip',
      default=False,
      action='store_true',
      help="""\
        Connect to instances using their internal IP addresses rather than their
        external IP addresses. Use this to connect from one instance to another
        on the same VPC network, over a VPN connection, or between two peered
        VPC networks.

        For this connection to work, you must configure your networks and
        firewall to allow SSH connections to the internal IP address of
        the instance to which you want to connect.

        To learn how to use this flag, see
        [](https://cloud.google.com/compute/docs/instances/connecting-advanced#sshbetweeninstances).
        """,
  )


def TroubleshootHelp():
  """Generate the help text for troubleshot argument."""
  help_text = """\
          If you can't connect to a virtual machine (VM) instance using SSH, you can investigate the problem using the `--troubleshoot` flag:

            $ {command} VM_NAME --zone=ZONE --troubleshoot"""
  if base_classes.SupportIAP():
    help_text += """ [--tunnel-through-iap]"""
  help_text += """

          The troubleshoot flag runs tests and returns recommendations for the following types of issues:
          - VM status"""
  if base_classes.SupportNetworkConnectivityTest():
    help_text += """
          - Network connectivity"""
  help_text += """
          - User permissions
          - Virtual Private Cloud (VPC) settings
          - VM boot"""
  if base_classes.SupportIAP():
    help_text += """

          If you specify the `--tunnel-through-iap` flag, the tool also checks IAP port forwarding."""
  return help_text + """
          """


def RecommendMessage(
    release_track: str,
    project_name: str,
    zone_name: str,
    instance_name: str,
    ssh_key_file: str,
    force_key_file_overwrite: bool,
) -> str:
  """Generate the recommend message for troubleshot."""
  command = 'gcloud {0}compute ssh {1} --project={2} --zone={3} '.format(
      release_track, instance_name, project_name, zone_name
  )
  if ssh_key_file:
    command += '--ssh-key-file={0} '.format(ssh_key_file)
  if force_key_file_overwrite:
    command += '--force-key-file-overwrite '
  command += '--troubleshoot'
  recommend_message = RECOMMEND_MESSAGE.format(command)
  if not base_classes.SupportIAP():
    return recommend_message
  recommend_iap = _RECOMMEND_IAP.format(command + ' --tunnel-through-iap')
  return recommend_message + recommend_iap


def AddTroubleshootArg(parser):
  parser.add_argument(
      '--troubleshoot', action='store_true', help=TroubleshootHelp()
  )


# pylint: disable=unused-argument
def RunTroubleshooting(
    project=None, zone=None, instance=None, iap_tunnel_args=None
):
  """Run each category of troubleshoot action."""
  if base_classes.SupportNetworkConnectivityTest():
    network_args = {
        'project': project,
        'zone': zone,
        'instance': instance,
        'iap_tunnel_args': iap_tunnel_args,
    }
    network = network_troubleshooter.NetworkTroubleshooter(**network_args)
    network()

  user_permission_args = {
      'project': project,
      'zone': zone,
      'instance': instance,
      'iap_tunnel_args': iap_tunnel_args,
  }
  user_permission = user_permission_troubleshooter.UserPermissionTroubleshooter(
      **user_permission_args
  )
  user_permission()

  vpc_args = {
      'project': project,
      'zone': zone,
      'instance': instance,
      'iap_tunnel_args': iap_tunnel_args,
  }
  vpc = vpc_troubleshooter.VPCTroubleshooter(**vpc_args)
  vpc()

  vm_status_args = {
      'project': project,
      'zone': zone,
      'instance': instance,
  }
  vm_status = vm_status_troubleshooter.VMStatusTroubleshooter(**vm_status_args)
  vm_status()

  vm_boot_args = {
      'project': project,
      'zone': zone,
      'instance': instance,
  }
  vm_boot = vm_boot_troubleshooter.VMBootTroubleshooter(**vm_boot_args)
  vm_boot()


@base.UniverseCompatible
@base.ReleaseTracks(base.ReleaseTrack.GA)
class Ssh(base.Command):
  """SSH into a virtual machine instance."""

  category = base.TOOLS_CATEGORY
  enable_security_keys = False

  @classmethod
  def Args(cls, parser):
    """Set up arguments for this command.

    Args:
      parser: An argparse.ArgumentParser.
    """
    ssh_utils.BaseSSHCLIHelper.Args(parser)
    AddCommandArg(parser)
    AddSSHArgs(parser)
    AddContainerArg(parser)
    AddTroubleshootArg(parser)
    if base_classes.SupportIAP():
      iap_tunnel.AddHostBasedTunnelArgs(parser)

    flags.AddZoneFlag(
        parser, resource_type='instance', operation_type='connect to'
    )
    ssh_utils.AddVerifyInternalIpArg(parser)

    routing_group = parser.add_mutually_exclusive_group()
    AddInternalIPArg(routing_group)
    if base_classes.SupportIAP():
      iap_tunnel.AddSshTunnelArgs(parser, routing_group)

  def Run(self, args):
    """See ssh_utils.BaseSSHCLICommand.Run."""

    on_prem = args.IsKnownAndSpecified('network') and args.IsKnownAndSpecified(
        'region'
    )
    if on_prem:
      args.plain = True

    # These two lines are needed to ensure reauth is performed as needed, even
    # for on-prem, which doesn't use the resulting variables.
    holder = base_classes.ComputeApiHolder(self.ReleaseTrack())
    client = holder.client

    ssh_helper = ssh_utils.BaseSSHCLIHelper()
    ssh_helper.Run(args)

    iap_tunnel_args = None
    project = None
    instance = None
    instance_ref = None
    instance_name = None
    host_keys = None
    expiration = None

    if on_prem:
      user, ip = ssh_utils.GetUserAndInstance(args.user_host)
      remote = ssh.Remote(ip, user)

      if base_classes.SupportIAP():
        iap_tunnel_args = iap_tunnel.CreateOnPremSshTunnelArgs(
            args, self.ReleaseTrack(), ip
        )
      instance_address = ip
      internal_address = ip
      oslogin_state = ssh.OsloginState()
    else:
      user, instance_name = ssh_utils.GetUserAndInstance(args.user_host)
      instance_ref = instance_flags.SSH_INSTANCE_RESOLVER.ResolveResources(
          [instance_name],
          compute_scope.ScopeEnum.ZONE,
          args.zone,
          holder.resources,
          scope_lister=instance_flags.GetInstanceZoneScopeLister(client),
      )[0]
      instance = ssh_helper.GetInstance(client, instance_ref)
      project = ssh_helper.GetProject(client, instance_ref.project)
      if args.strict_host_key_checking == 'no':
        host_keys = None
      else:
        host_keys = ssh_helper.GetHostKeysFromGuestAttributes(
            client, instance_ref, instance, project
        )

      if base_classes.SupportIAP():
        iap_tunnel_args = iap_tunnel.CreateSshTunnelArgs(
            args,
            self.ReleaseTrack(),
            instance_ref,
            ssh_utils.GetExternalInterface(instance, no_raise=True),
        )

      internal_address = ssh_utils.GetInternalIPAddress(instance)

      if args.troubleshoot:
        log.status.Print(
            TROUBLESHOOT_HEADER.format(
                instance_ref,
                args.zone or instance_ref.zone,
                datetime.datetime.now(),
            )
        )
        RunTroubleshooting(
            project, args.zone or instance_ref.zone, instance, iap_tunnel_args
        )
        return

      if not host_keys and host_keys is not None:
        log.debug(
            'Unable to retrieve host keys from instance metadata. Continuing.'
        )
      expiration, expiration_micros = ssh_utils.GetSSHKeyExpirationFromArgs(
          args
      )

      if args.plain:
        oslogin_state = ssh.OsloginState(oslogin_enabled=False)
      else:
        public_key = (
            ssh_helper.keys.GetPublicKey().ToEntry(include_comment=True)
            if ssh_helper.keys
            else None
        )
        # If there is an '@' symbol in the user_host arg, the user is requesting
        # to connect as a specific user. This may get overridden by OS Login.
        username_requested = '@' in args.user_host
        oslogin_state = ssh.GetOsloginState(
            instance,
            project,
            user,
            public_key,
            expiration_micros,
            self.ReleaseTrack(),
            username_requested=username_requested,
            messages=holder.client.messages,
        )
        user = oslogin_state.user
      log.debug(oslogin_state)

      if iap_tunnel_args:
        # IAP Tunnel only uses instance_address for the purpose of --ssh-flag
        # substitution. In this case, dest_addr doesn't do much, it just matches
        # against entries in the user's ssh_config file. It's best to use
        # something unique to avoid false positive matches, thus we use
        # HostKeyAlias.
        instance_address = internal_address
        dest_addr = ssh_utils.HostKeyAlias(instance)
      elif args.internal_ip:
        instance_address = internal_address
        dest_addr = instance_address
      else:
        instance_address = ssh_utils.GetExternalIPAddress(instance)
        dest_addr = instance_address
      remote = ssh.Remote(dest_addr, user)

    # identity_file_list will be None if security keys are not enabled.
    identity_file_list = ssh.WriteSecurityKeys(oslogin_state)
    identity_file = None
    cert_file = None
    options = None
    if not args.plain:
      if not identity_file_list:
        identity_file = ssh_helper.keys.key_file if ssh_helper.keys else None
      options = ssh_helper.GetConfig(
          ssh_utils.HostKeyAlias(instance),
          args.strict_host_key_checking,
          host_keys_to_add=host_keys,
      )

    if (
        oslogin_state.third_party_user
        or oslogin_state.require_certificates
        or not oslogin_state.is_default_universe
    ):
      cert_file = ssh.CertFileFromComputeInstance(
          project.name, instance_ref.zone, instance.id
      )

    extra_flags = ssh.ParseAndSubstituteSSHFlags(
        args, remote, instance_address, internal_address
    )
    remainder = []

    if args.ssh_args:
      remainder.extend(args.ssh_args)

    # Transform args.command into arg list or None if no command
    command_list = args.command.split(' ') if args.command else None
    tty = containers.GetTty(args.container, command_list)
    remote_command = containers.GetRemoteCommand(args.container, command_list)

    # Do not include default port since that will prevent users from
    # specifying a custom port (b/121998342).
    cmd = ssh.SSHCommand(
        remote=remote,
        identity_file=identity_file,
        cert_file=cert_file,
        options=options,
        extra_flags=extra_flags,
        remote_command=remote_command,
        tty=tty,
        iap_tunnel_args=iap_tunnel_args,
        remainder=remainder,
        identity_list=identity_file_list,
    )

    if args.dry_run:
      # Add quotes around any arguments that contain spaces.
      log.out.Print(
          ' '.join(
              '"{0}"'.format(arg) if ' ' in arg else arg
              for arg in cmd.Build(ssh_helper.env)
          )
      )
      return

    # Raise errors if instance requires a security key but the local
    # envionment doesn't support them. This is after the 'dry-run' because
    # we want to allow printing the command regardless.
    if self.enable_security_keys:
      ssh_utils.ConfirmSecurityKeyStatus(oslogin_state)

    # TODO(b/35355795): Don't force connect in general.
    # At a minimum, avoid injecting 'y' if PuTTY will prompt for a password /
    # 2FA authentication method (since we know that won't work), or if the user
    # has disabled the property.
    prompt_for_password = args.plain and not any(
        f == '-i' or f.startswith('-i=') for f in extra_flags
    )
    putty_force_connect = (
        not prompt_for_password
        and not oslogin_state.oslogin_2fa_enabled
        and properties.VALUES.ssh.putty_force_connect.GetBool()
    )

    if args.plain or oslogin_state.oslogin_enabled:
      keys_newly_added = False
    else:
      keys_newly_added = ssh_helper.EnsureSSHKeyExists(
          client, remote.user, instance, project, expiration=expiration
      )

    if keys_newly_added:
      poller = ssh_utils.CreateSSHPoller(
          remote,
          identity_file,
          options,
          iap_tunnel_args,
          extra_flags=extra_flags,
      )
      log.status.Print('Waiting for SSH key to propagate.')
      try:
        poller.Poll(ssh_helper.env, putty_force_connect=putty_force_connect)
      except retry.WaitException:
        raise ssh_utils.NetworkError()

    if args.internal_ip and not on_prem:
      ssh_helper.PreliminarilyVerifyInstance(
          instance.id, remote, identity_file, options, putty_force_connect
      )

    # Errors from SSH itself result in an ssh.CommandError being raised
    try:
      return_code = cmd.Run(
          ssh_helper.env, putty_force_connect=putty_force_connect
      )
    except ssh.CommandError as e:
      if not on_prem:
        log.status.Print(
            self.createRecommendMessage(
                args, instance_name, instance_ref, project
            )
        )
      raise e

    if cert_file:
      ssh.DeleteCertificateFile(project.name, instance_ref.zone, instance.id)

    if return_code:
      # This is the return code of the remote command.  Problems with SSH itself
      # will result in ssh.CommandError being raised above.
      sys.exit(return_code)

  def createRecommendMessage(self, args, instance_name, instance_ref, project):
    release_track = _RELEASE_TRACK.get(str(self.ReleaseTrack()).lower())
    release_track = release_track + ' ' if release_track else ''
    zone_name = args.zone or instance_ref.zone
    project_name = project.name
    return RecommendMessage(
        release_track,
        project_name,
        zone_name,
        instance_name,
        args.ssh_key_file,
        args.force_key_file_overwrite,
    )


@base.UniverseCompatible
@base.ReleaseTracks(base.ReleaseTrack.ALPHA, base.ReleaseTrack.BETA)
class SshAlphaBeta(Ssh):
  """SSH into a virtual machine instance (Beta)."""

  enable_security_keys = True


def _DetailedHelp():
  """Construct help text based on the command release track."""
  detailed_help = {
      'brief': 'SSH into a virtual machine instance',
      'DESCRIPTION': (
          """\
*{command}* is a thin wrapper around the *ssh(1)* command that
takes care of authentication and the translation of the
instance name into an IP address.

To use SSH to connect to a Windows VM, refer to this guide:
https://cloud.google.com/compute/docs/connect/windows-ssh

The default network comes preconfigured to allow ssh access to
all VMs. If the default network was edited, or if not using the
default network, you may need to explicitly enable ssh access by adding
a firewall-rule:

  $ gcloud compute firewall-rules create --network=NETWORK default-allow-ssh --allow=tcp:22

*{command}* ensures that the user's public SSH key is present
in the project's metadata. If the user does not have a public
SSH key, one is generated using *ssh-keygen(1)* (if the `--quiet`
flag is given, the generated key will have an empty passphrase).

If the `--region` and `--network` flags are provided, then `--plain` and
`--tunnel-through-iap` are implied and an IP address must be supplied instead of
an instance name. This is most useful for connecting to on-prem resources.
"""
      ),
      'EXAMPLES': (
          """\
To SSH into 'example-instance' in zone ``us-central1-a'', run:

  $ {command} example-instance --zone=us-central1-a

You can also run a command on the virtual machine. For
example, to get a snapshot of the guest's process tree, run:

  $ {command} example-instance --zone=us-central1-a --command="ps -ejH"

When running a command on a virtual machine, a non-interactive shell will
typically be used. (See the INVOCATION section of
https://linux.die.net/man/1/bash for an overview.) That behavior can be
overridden by specifying a shell to run the command, and passing the `-t` flag
to SSH to allocate a pseudo-TTY. For example, to see the environment variables
set during an interactive session, run:

  $ {command} example-instance --zone=us-central1-a --command="bash -i -c env" -- -t

If you are using the Google Container-Optimized virtual machine image,
you can SSH into one of your containers with:

  $ {command} example-instance --zone=us-central1-a --container=CONTAINER

You can limit the allowed time to ssh. For example, to allow a key to be
used through 2019:

  $ {command} example-instance --zone=us-central1-a --ssh-key-expiration="2020-01-01T00:00:00:00Z"

Or alternatively, allow access for the next two minutes:

  $ {command} example-instance --zone=us-central1-a --ssh-key-expire-after=2m

To use the IP address of your remote VM (eg, for on-prem), you must also specify
the `--region` and `--network` flags:

  $ {command} 10.1.2.3 --region=us-central1 --network=default
"""
      ),
  }

  return detailed_help


SshAlphaBeta.detailed_help = _DetailedHelp()
Ssh.detailed_help = _DetailedHelp()
