github.com/niedbalski/juju@v0.0.0-20190215020005-8ff100488e47/acceptancetests/remote.py (about)

     1  """Remote helper class for communicating with juju machines."""
     2  import abc
     3  import logging
     4  import os
     5  import subprocess
     6  import sys
     7  import zlib
     8  
     9  import winrm
    10  
    11  import jujupy
    12  import utility
    13  
    14  
    15  __metaclass__ = type
    16  
    17  
    18  def _remote_for_series(series):
    19      """Give an appropriate remote class based on machine series."""
    20      if series is not None and series.startswith("win"):
    21          return WinRmRemote
    22      return SSHRemote
    23  
    24  
    25  def remote_from_unit(client, unit, series=None, status=None):
    26      """Create remote instance given a juju client and a unit."""
    27      if series is None:
    28          if status is None:
    29              status = client.get_status()
    30          machine = status.get_unit(unit).get("machine")
    31          if machine is not None:
    32              series = status.status["machines"].get(machine, {}).get("series")
    33      remotecls = _remote_for_series(series)
    34      return remotecls(client, unit, None, series=series, status=status)
    35  
    36  
    37  def remote_from_address(address, series=None):
    38      """Create remote instance given an address"""
    39      remotecls = _remote_for_series(series)
    40      return remotecls(None, None, address, series=series)
    41  
    42  
    43  class _Remote:
    44      """_Remote represents a juju machine to access over the network."""
    45  
    46      __metaclass__ = abc.ABCMeta
    47  
    48      def __init__(self, client, unit, address, series=None, status=None):
    49          if address is None and (client is None or unit is None):
    50              raise ValueError("Remote needs either address or client and unit")
    51          self.client = client
    52          self.unit = unit
    53          self.use_juju_ssh = unit is not None
    54          self.address = address
    55          self.series = series
    56          self.status = status
    57  
    58      def __repr__(self):
    59          params = []
    60          if self.client is not None:
    61              params.append("env=" + repr(self.client.env.environment))
    62          if self.unit is not None:
    63              params.append("unit=" + repr(self.unit))
    64          if self.address is not None:
    65              params.append("addr=" + repr(self.address))
    66          return "<{} {}>".format(self.__class__.__name__, " ".join(params))
    67  
    68      @abc.abstractmethod
    69      def cat(self, filename):
    70          """
    71          Get the contents of filename from the remote machine.
    72  
    73          Environment variables in the filename will be expanded in a according
    74          to platform-specific rules.
    75          """
    76  
    77      @abc.abstractmethod
    78      def copy(self, destination_dir, source_globs):
    79          """Copy files from the remote machine."""
    80  
    81      def is_windows(self):
    82          """Returns True if remote machine is running windows."""
    83          return self.series and self.series.startswith("win")
    84  
    85      def get_address(self):
    86          """Gives the address of the remote machine."""
    87          self._ensure_address()
    88          return self.address
    89  
    90      def update_address(self, address):
    91          """Change address of remote machine."""
    92          self.address = address
    93  
    94      def _get_status(self):
    95          if self.status is None:
    96              self.status = self.client.get_status()
    97          return self.status
    98  
    99      def _ensure_address(self):
   100          if self.address:
   101              return
   102          if self.client is None:
   103              raise ValueError("No address or client supplied")
   104          status = self._get_status()
   105          unit = status.get_unit(self.unit)
   106          if 'public-address' not in unit:
   107              raise ValueError("No public address for unit: {!r} {!r}".format(
   108                  self.unit, unit))
   109          self.address = unit['public-address']
   110  
   111  
   112  def _default_is_command_error(err):
   113      """
   114      Whether to treat error as issue with remote command rather than ssh.
   115  
   116      This is a conservative default, remote commands may return a variety of
   117      other return codes. However, as the fallback to local ssh binary will
   118      repeat the command, those problems will be exposed later anyway.
   119      """
   120      return err.returncode == 1
   121  
   122  
   123  def _no_platform_ssh():
   124      """True if no openssh binary is available on this platform."""
   125      return sys.platform == "win32"
   126  
   127  
   128  class SSHRemote(_Remote):
   129      """SSHRemote represents a juju machine to access using ssh."""
   130  
   131      _ssh_opts = [
   132          "-o", "User ubuntu",
   133          "-o", "UserKnownHostsFile /dev/null",
   134          "-o", "StrictHostKeyChecking no",
   135          "-o", "PasswordAuthentication no",
   136      ]
   137  
   138      # Limit each operation over SSH to 2 minutes by default
   139      timeout = 120
   140  
   141      def run(self, command_args, is_command_error=_default_is_command_error):
   142          """
   143          Run a command on the remote machine.
   144  
   145          If the remote instance has a juju unit run will default to using the
   146          juju ssh command. Otherwise, or if that fails, it will fall back to
   147          using ssh directly.
   148  
   149          The command_args param is a string or list of arguments to be invoked
   150          on the remote machine. A string must be given if special shell
   151          characters are used.
   152  
   153          The is_command_error param is a function that takes an instance of
   154          CalledProcessError and returns whether that error comes from the
   155          command being run rather than ssh itself. This can be used to skip the
   156          fallback to native ssh behaviour when running commands that may fail.
   157          """
   158          if not isinstance(command_args, (list, tuple)):
   159              command_args = [command_args]
   160          if self.use_juju_ssh:
   161              logging.debug('juju ssh {}'.format(self.unit))
   162              try:
   163                  return self.client.get_juju_output(
   164                      "ssh", self.unit, *command_args, timeout=self.timeout)
   165              except subprocess.CalledProcessError as e:
   166                  logging.warning(
   167                      "juju ssh to {!r} failed, returncode: {} output: {!r}"
   168                      " stderr: {!r}".format(
   169                          self.unit, e.returncode, e.output,
   170                          getattr(e, "stderr", None)))
   171                  # Don't fallback to calling ssh directly if command really
   172                  # failed or if there is likely to be no usable ssh client.
   173                  if is_command_error(e) or _no_platform_ssh():
   174                      raise
   175                  self.use_juju_ssh = False
   176              self._ensure_address()
   177          args = ["ssh"]
   178          args.extend(self._ssh_opts)
   179          args.append(self.address)
   180          args.extend(command_args)
   181          logging.debug(' '.join(utility.quote(i) for i in args))
   182          return self._run_subprocess(args)
   183  
   184      def copy(self, destination_dir, source_globs):
   185          """Copy files from the remote machine."""
   186          self._ensure_address()
   187          args = ["scp", "-rC"]
   188          args.extend(self._ssh_opts)
   189          address = utility.as_literal_address(self.address)
   190          args.extend(["{}:{}".format(address, f) for f in source_globs])
   191          args.append(destination_dir)
   192          self._run_subprocess(args)
   193  
   194      def cat(self, filename):
   195          """
   196          Get the contents of filename from the remote machine.
   197  
   198          Tildes and environment variables in the form $TMP will be expanded.
   199          """
   200          return self.run(["cat", filename])
   201  
   202      def _run_subprocess(self, command):
   203          if self.timeout:
   204              command = jujupy.get_timeout_prefix(self.timeout) + tuple(command)
   205          return subprocess.check_output(command, stdin=subprocess.PIPE)
   206  
   207  
   208  class _SSLSession(winrm.Session):
   209  
   210      def __init__(self, target, auth, transport="ssl"):
   211          key, cert = auth
   212          self.url = self._build_url(target, transport)
   213          self.protocol = winrm.Protocol(self.url, transport=transport,
   214                                         cert_key_pem=key, cert_pem=cert)
   215  
   216  
   217  _ps_copy_script = """\
   218  $ErrorActionPreference = "Stop"
   219  
   220  function OutputEncodedFile {
   221      param([String]$filename, [IO.Stream]$instream)
   222      $trans = New-Object Security.Cryptography.ToBase64Transform
   223      $out = [Console]::OpenStandardOutput()
   224      $bs = New-Object Security.Cryptography.CryptoStream($out, $trans,
   225          [Security.Cryptography.CryptoStreamMode]::Write)
   226      $zs = New-Object IO.Compression.DeflateStream($bs,
   227          [IO.Compression.CompressionMode]::Compress)
   228      [Console]::Out.Write($filename + "|")
   229      try {
   230          $instream.CopyTo($zs)
   231      } finally {
   232          $zs.close()
   233          $bs.close()
   234          [Console]::Out.Write("`n")
   235      }
   236  }
   237  
   238  function GatherFiles {
   239      param([String[]]$patterns)
   240      ForEach ($pattern in $patterns) {
   241          $path = [Environment]::ExpandEnvironmentVariables($pattern)
   242          ForEach ($file in Get-Item -path $path) {
   243              try {
   244                  $in = New-Object IO.FileStream($file, [IO.FileMode]::Open,
   245                      [IO.FileAccess]::Read, [IO.FileShare]"ReadWrite,Delete")
   246                  OutputEncodedFile -filename $file.name -instream $in
   247              } catch {
   248                  $utf8 = New-Object Text.UTF8Encoding($False)
   249                  $errstream = New-Object IO.MemoryStream(
   250                      $utf8.GetBytes($_.Exception), $False)
   251                  $errfilename = $file.name + ".copyerror"
   252                  OutputEncodedFile -filename $errfilename -instream $errstream
   253              }
   254          }
   255      }
   256  }
   257  
   258  try {
   259      GatherFiles -patterns @(%s)
   260  } catch {
   261      Write-Error $_.Exception
   262      exit 1
   263  }
   264  """
   265  
   266  
   267  class WinRmRemote(_Remote):
   268      """WinRmRemote represents a juju machine to access using winrm."""
   269  
   270      def __init__(self, *args, **kwargs):
   271          super(WinRmRemote, self).__init__(*args, **kwargs)
   272          self._ensure_address()
   273          self.use_juju_ssh = False
   274          self.certs = utility.get_winrm_certs()
   275          self.session = _SSLSession(self.address, self.certs)
   276  
   277      def update_address(self, address):
   278          """Change address of remote machine, refreshes the winrm session."""
   279          self.address = address
   280          self.session = _SSLSession(self.address, self.certs)
   281  
   282      _escape = staticmethod(subprocess.list2cmdline)
   283  
   284      def run_cmd(self, cmd_list):
   285          """Run cmd and arguments given as a list returning response object."""
   286          if isinstance(cmd_list, basestring):
   287              raise ValueError("run_cmd requires a list not a string")
   288          # pywinrm does not correctly escape arguments, fix up by escaping cmd
   289          # and giving args as a list of a single pre-escaped string.
   290          cmd = self._escape(cmd_list[:1])
   291          args = [self._escape(cmd_list[1:])]
   292          return self.session.run_cmd(cmd, args)
   293  
   294      def run_ps(self, script):
   295          """Run string of powershell returning response object."""
   296          return self.session.run_ps(script)
   297  
   298      def cat(self, filename):
   299          """
   300          Get the contents of filename from the remote machine.
   301  
   302          Backslashes will be treated as directory seperators. Environment
   303          variables in the form %TMP% will be expanded.
   304          """
   305          result = self.session.run_cmd("type", [self._escape([filename])])
   306          if result.status_code:
   307              logging.warning("winrm cat failed %r", result)
   308          return result.std_out
   309  
   310      # TODO(gz): Unlike SSHRemote.copy this only supports copying files, not
   311      #           directories and their content. Both the powershell script and
   312      #           the unpacking method will need updating to support that.
   313      def copy(self, destination_dir, source_globs):
   314          """Copy files from the remote machine."""
   315          # Encode globs into script to run on remote machine and return result.
   316          script = _ps_copy_script % ",".join(s.join('""') for s in source_globs)
   317          result = self.run_ps(script)
   318          if result.status_code:
   319              logging.warning("winrm copy stderr:\n%s", result.std_err)
   320              raise subprocess.CalledProcessError(result.status_code,
   321                                                  "powershell", result)
   322          self._encoded_copy_to_dir(destination_dir, result.std_out)
   323  
   324      @staticmethod
   325      def _encoded_copy_to_dir(destination_dir, output):
   326          """Write remote files from powershell script to disk.
   327  
   328          The given output from the powershell script is one line per file, with
   329          the filename first, then a pipe, then the base64 encoded deflated file
   330          contents. This method reverses that process and creates the files in
   331          the given destination_dir.
   332          """
   333          start = 0
   334          while True:
   335              end = output.find("\n", start)
   336              if end == -1:
   337                  break
   338              mid = output.find("|", start, end)
   339              if mid == -1:
   340                  if not output[start:end].rstrip("\r\n"):
   341                      break
   342                  raise ValueError("missing filename in encoded copy data")
   343              filename = output[start:mid]
   344              if "/" in filename:
   345                  # Just defense against path traversal bugs, should never reach.
   346                  raise ValueError("path not filename {!r}".format(filename))
   347              with open(os.path.join(destination_dir, filename), "wb") as f:
   348                  f.write(zlib.decompress(output[mid + 1:end].decode("base64"),
   349                                          -zlib.MAX_WBITS))
   350              start = end + 1