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

     1  # This file is part of JujuPy, a library for driving the Juju CLI.
     2  # Copyright 2013-2018 Canonical Ltd.
     3  #
     4  # This program is free software: you can redistribute it and/or modify it
     5  # under the terms of the Lesser GNU General Public License version 3, as
     6  # published by the Free Software Foundation.
     7  #
     8  # This program is distributed in the hope that it will be useful, but WITHOUT
     9  # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
    10  # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE.  See the Lesser
    11  # GNU General Public License for more details.
    12  #
    13  # You should have received a copy of the Lesser GNU General Public License
    14  # along with this program.  If not, see <http://www.gnu.org/licenses/>.
    15  
    16  # Provides functionality for running a local streams server.
    17  
    18  from __future__ import print_function
    19  
    20  try:
    21      from BaseHTTPServer import HTTPServer
    22  except ImportError:
    23      from http.server import HTTPServer
    24  
    25  try:
    26      from SimpleHTTPServer import SimpleHTTPRequestHandler
    27  except ImportError:
    28      from http.server import SimpleHTTPRequestHandler
    29  
    30  from datetime import datetime
    31  import multiprocessing
    32  import hashlib
    33  import logging
    34  import os
    35  import shutil
    36  import socket
    37  import subprocess
    38  import tarfile
    39  
    40  from contextlib import contextmanager
    41  
    42  from jujupy.client import (
    43      get_version_string_parts
    44  )
    45  
    46  __metaclass__ = type
    47  
    48  
    49  log = logging.getLogger(__name__)
    50  
    51  
    52  class _JujuStreamData:
    53      """Models stream metadata. Best used via StreamServer."""
    54  
    55      def __init__(self, working_dir):
    56          """Models Juju product simplestream metadata.
    57  
    58          :param working_dir: Directory in which to copy agent tarballs and
    59            generate stream json.
    60          """
    61          self.products = []
    62  
    63          self._agent_path = os.path.join(working_dir, 'agent')
    64          self._stream_path = working_dir
    65  
    66          os.makedirs(self._agent_path)
    67  
    68      def add_product(self, content_id, version, arch, series, agent_tgz_path):
    69          """Add a new product to generate stream data for.
    70  
    71          :param content_id: String ID (e.g.'proposed', 'release')
    72          :param version: Juju version string for product (i.e. '2.3.3')
    73          :param arch: Architecture string of this product (e.g. 's390x','amd64')
    74          :param series: Series string that appears in item_name
    75            (e.g. 'bionic', 'xenial', 'centos')
    76          :param agent_tgz_path: String full path to agent tarball file to use.
    77            This file is copied into the JujuStreamData working dir to be served
    78            up at a later date.
    79          """
    80          shutil.copy(agent_tgz_path, self._agent_path)
    81          product_dict = _generate_product_json(
    82              content_id, version, arch, series, agent_tgz_path)
    83          self.products.append(product_dict)
    84  
    85      def generate_stream_data(self):
    86          """Generate metadata from added products into working dir."""
    87          # Late import as simplestreams.log overwrites logging handlers.
    88          from simplestreams.json2streams import (
    89              dict_to_item,
    90              write_juju_streams
    91          )
    92          from simplestreams.generate_simplestreams import items2content_trees
    93          # The following has been cribbed from simplestreams.json2streams.
    94          # Doing so saves the need to create json files to then shell out to
    95          # read those files into memory to generate the resulting json files.
    96          items = (dict_to_item(item.copy()) for item in self.products)
    97          updated = datetime.utcnow().strftime(
    98              '%a, %d %b %Y %H:%M:%S +0000')
    99          data = {'updated': updated, 'datatype': 'content-download'}
   100          trees = items2content_trees(items, data)
   101          return write_juju_streams(self._stream_path, trees, updated)
   102  
   103  
   104  class StreamServer:
   105      """Provide service to create stream metadata and to serve it."""
   106  
   107      def __init__(self, base_dir, stream_data_type=_JujuStreamData):
   108          self.base_dir = base_dir
   109          self.stream_data = stream_data_type(base_dir)
   110  
   111      def add_product(self, content_id, version, arch, series, agent_tgz_path):
   112          """Add a new product to generate stream data for.
   113  
   114          :param content_id: String ID (e.g.'proposed', 'released')
   115          :param version: Juju version string for product (i.e. '2.3.3')
   116          :param arch: Architecture string of this product (e.g. 's390x','amd64')
   117          :param series: Series string that appears in item_name
   118            (e.g. 'bionic', 'xenial', 'centos')
   119          :param agent_tgz_path: String full path to agent tarball file to use.
   120            This file is copied into the JujuStreamData working dir to be served
   121            up at a later date.
   122          """
   123          self.stream_data.add_product(
   124              content_id, version, arch, series, agent_tgz_path)
   125          # Re-generate when adding a product allows updating the server while
   126          # running.
   127          # Can be noisey in the logs, if a lot of products need to be added can
   128          # use StreamServer.stream_data.add_product() directly.
   129          self.stream_data.generate_stream_data()
   130  
   131      @contextmanager
   132      def server(self):
   133          """Serves the products that have been added up until this point.
   134  
   135          :yields: The http address of the server, including the port used.
   136          """
   137          self.stream_data.generate_stream_data()
   138          server = _create_stream_server()
   139          ip_address, port = _get_server_address(server)
   140          address = 'http://{}:{}'.format(ip_address, port)
   141          server_process = multiprocessing.Process(
   142              target=_http_worker,
   143              args=(server, self.base_dir),
   144              name='SimlestreamServer')
   145          try:
   146              log.info('Starting stream server at: {}'.format(address))
   147              multiprocessing.log_to_stderr(logging.DEBUG)
   148              server_process.start()
   149              yield address
   150          finally:
   151              log.info('Terminating stream server')
   152              server_process.terminate()
   153              server_process.join()
   154  
   155  
   156  def agent_tgz_from_juju_binary(
   157          juju_bin_path, tmp_dir, series=None, force_version=None):
   158      """
   159      Create agent tarball with jujud found with provided juju binary.
   160  
   161      Search the location where `juju_bin_path` resides to attempt to find a
   162      jujud in the same location.
   163  
   164      :param juju_bin_path: The path to the juju bin in use.
   165      :param tmp_dir: Location to store the generated agent file.
   166      :param series: String series to use instead of that of the passed binary.
   167        Allows one to overwrite the series of the juju client.
   168      :returns: String path to generated
   169      """
   170      def _series_lookup(series):
   171          # Handle the inconsistencies with agent series names.
   172          if series is None:
   173              return None
   174          if series.startswith('centos'):
   175              return series
   176          if series.startswith('win'):
   177              return 'win2012'
   178          return 'ubuntu'
   179  
   180      bin_dir = os.path.dirname(juju_bin_path)
   181      try:
   182          jujud_path = os.path.join(
   183              bin_dir,
   184              [f for f in os.listdir(bin_dir) if f == 'jujud'][0])
   185      except IndexError:
   186          raise RuntimeError('Unable to find jujud binary in {}'.format(bin_dir))
   187  
   188      try:
   189          version_output = subprocess.check_output(
   190              [jujud_path, 'version']).rstrip('\n')
   191          version, bin_series, arch = get_version_string_parts(version_output)
   192          bin_agent_series = _series_lookup(bin_series)
   193      except subprocess.CalledProcessError as e:
   194          raise RuntimeError(
   195              'Unable to query jujud for version details: {}'.format(e))
   196      except IndexError:
   197          raise RuntimeError(
   198              'Unable to determine version, series and arch from version '
   199              'string: {}'.format(version_output))
   200  
   201      version = force_version or version
   202      agent_tgz_name = 'juju-{version}-{series}-{arch}.tgz'.format(
   203          version=version,
   204          series=series if series else bin_agent_series,
   205          arch=arch
   206      )
   207  
   208      # It's possible we're re-generating a file.
   209      tgz_path = os.path.join(tmp_dir, agent_tgz_name)
   210      if os.path.exists(tgz_path):
   211          log.debug('Reusing agent file: {}'.format(agent_tgz_name))
   212          return tgz_path
   213  
   214      log.debug('Creating agent file: {}'.format(agent_tgz_name))
   215      with tarfile.open(tgz_path, 'w:gz') as tar:
   216          tar.add(jujud_path, arcname='jujud')
   217          if force_version is not None:
   218              force_version_file = os.path.join(tmp_dir, 'FORCE-VERSION')
   219              with open(force_version_file, 'wt') as f:
   220                  f.write(version)
   221              tar.add(force_version_file, arcname='FORCE-VERSION')
   222      return tgz_path
   223  
   224  
   225  def _generate_product_json(content_id, version, arch, series, agent_tgz_path):
   226      """Return dict containing product metadata from provided args."""
   227      series_name, series_code = _get_series_details(series)
   228      tgz_name = os.path.basename(agent_tgz_path)
   229      file_details = _get_tgz_file_details(agent_tgz_path)
   230      item_name = '{version}-{series}-{arch}'.format(
   231          version=version,
   232          series='{}:{}'.format(series_name, series_code),
   233          arch=arch)
   234      return dict(
   235          arch=arch,
   236          content_id='com.ubuntu.juju:{}:tools'.format(content_id),
   237          format='products:1.0',
   238          ftype='tar.gz',
   239          item_name=item_name,
   240          md5=file_details['md5'],
   241          path=os.path.join('agent', tgz_name),
   242          product_name='com.ubuntu.juju:{series_code}:{arch}'.format(
   243              series_code=series_code,
   244              arch=arch),
   245          release=series_name,
   246          sha256=file_details['sha256'],
   247          size=file_details['size'],
   248          version=version,
   249          version_name=datetime.utcnow().strftime('%Y%m%d')
   250      )
   251  
   252  
   253  def _get_series_details(series):
   254      # Ubuntu agents use series and a code (i.e. trusty:14.04), others don't.
   255      _series_lookup = dict(
   256          trusty=14.04,
   257          xenial=16.04,
   258          artful=17.10,
   259          bionic=18.04,
   260      )
   261      try:
   262          series_code = _series_lookup[series]
   263      except KeyError:
   264          return series, series
   265      return series, series_code
   266  
   267  
   268  def _get_tgz_file_details(agent_tgz_path):
   269      file_details = dict(size=os.path.getsize(agent_tgz_path))
   270      with open(agent_tgz_path) as f:
   271          content = f.read()
   272      for hashtype in 'md5', 'sha256':
   273          hash_obj = hashlib.new(hashtype)
   274          hash_obj.update(content)
   275          file_details[hashtype] = hash_obj.hexdigest()
   276  
   277      return file_details
   278  
   279  
   280  def _get_server_address(httpd_server):
   281      # Attempt to get the "primary" IP from this machine to provide an
   282      # addressable IP (not 0.0.0.0 or localhost etc.)
   283      # Taken from:
   284      # https://stackoverflow.com/questions/166506/finding-local-ip-addresses-using-pythons-stdlib
   285      s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
   286      try:
   287          # Just any ol' non-routable address.
   288          s.connect(('10.255.255.255', 0))
   289          return s.getsockname()[0], httpd_server.server_port
   290      except:
   291          raise RuntimeError('Unable to serve on an addressable IP.')
   292      finally:
   293          s.close()
   294  
   295  
   296  class _QuietHttpRequestHandler(SimpleHTTPRequestHandler):
   297  
   298      def log_message(self, format, *args):
   299          # Lessen the output
   300          log.debug('{} - - [{}] {}'.format(
   301              self.client_address[0],
   302              self.client_address[0],
   303              format % args))
   304  
   305  
   306  def _create_stream_server():
   307      server_details = ("", 0)
   308      httpd = HTTPServer(server_details, _QuietHttpRequestHandler)
   309      return httpd
   310  
   311  
   312  def _http_worker(httpd, serve_base):
   313      """Serve `serve_base` dir using `httpd` SocketServer.TCPServer object."""
   314      log.debug('Starting server with root: "{}"'.format(serve_base))
   315      try:
   316          os.chdir(serve_base)
   317          httpd.serve_forever()
   318      except Exception as e:
   319          print('Exiting due to exception: {}'.format(e))