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))