aae821efb9dd3d5cf34283ab7b901dde3bc9d468
[apex.git] / apex / common / utils.py
1 ##############################################################################
2 # Copyright (c) 2016 Tim Rozet (trozet@redhat.com) and others.
3 #
4 # All rights reserved. This program and the accompanying materials
5 # are made available under the terms of the Apache License, Version 2.0
6 # which accompanies this distribution, and is available at
7 # http://www.apache.org/licenses/LICENSE-2.0
8 ##############################################################################
9
10 import datetime
11 import distro
12 import json
13 import logging
14 import os
15 import pprint
16 import socket
17 import subprocess
18 import tarfile
19 import time
20 import urllib.error
21 import urllib.request
22 import urllib.parse
23 import yaml
24
25 from apex.common import exceptions as exc
26
27
28 def str2bool(var):
29     if isinstance(var, bool):
30         return var
31     else:
32         return var.lower() in ("true", "yes")
33
34
35 def parse_yaml(yaml_file):
36     with open(yaml_file) as f:
37         parsed_dict = yaml.safe_load(f)
38         return parsed_dict
39
40
41 def dump_yaml(data, file):
42     """
43     Dumps data to a file as yaml
44     :param data: yaml to be written to file
45     :param file: filename to write to
46     :return:
47     """
48     logging.debug("Writing file {} with "
49                   "yaml data:\n{}".format(file, yaml.safe_dump(data)))
50     with open(file, "w") as fh:
51         yaml.safe_dump(data, fh, default_flow_style=False)
52
53
54 def dict_objects_to_str(dictionary):
55         if isinstance(dictionary, list):
56             tmp_list = []
57             for element in dictionary:
58                 if isinstance(element, dict):
59                     tmp_list.append(dict_objects_to_str(element))
60                 else:
61                     tmp_list.append(str(element))
62             return tmp_list
63         elif not isinstance(dictionary, dict):
64             if not isinstance(dictionary, bool):
65                 return str(dictionary)
66             else:
67                 return dictionary
68         return dict((k, dict_objects_to_str(v)) for
69                     k, v in dictionary.items())
70
71
72 def run_ansible(ansible_vars, playbook, host='localhost', user='root',
73                 tmp_dir=None, dry_run=False):
74     """
75     Executes ansible playbook and checks for errors
76     :param ansible_vars: dictionary of variables to inject into ansible run
77     :param playbook: playbook to execute
78     :param host: inventory file or string of target hosts
79     :param user: remote user to run ansible tasks
80     :param tmp_dir: temp directory to store ansible command
81     :param dry_run: Do not actually apply changes
82     :return: None
83     """
84     logging.info("Executing ansible playbook: {}".format(playbook))
85     if not os.path.isfile(host):
86         inv_host = "{},".format(host)
87     else:
88         inv_host = host
89     if host == 'localhost':
90         conn_type = 'local'
91     else:
92         conn_type = 'smart'
93     ansible_command = ['ansible-playbook', '--become', '-i', inv_host,
94                        '-u', user, '-c', conn_type, '-T', '30',
95                        playbook, '-vv']
96     if dry_run:
97         ansible_command.append('--check')
98
99     if isinstance(ansible_vars, dict) and ansible_vars:
100         logging.debug("Ansible variables to be set:\n{}".format(
101             pprint.pformat(ansible_vars)))
102         ansible_command.append('--extra-vars')
103         ansible_command.append(json.dumps(ansible_vars))
104         if tmp_dir:
105             ansible_tmp = os.path.join(tmp_dir,
106                                        os.path.basename(playbook) + '.rerun')
107             # FIXME(trozet): extra vars are printed without single quotes
108             # so a dev has to add them manually to the command to rerun
109             # the playbook.  Need to test if we can just add the single quotes
110             # to the json dumps to the ansible command and see if that works
111             with open(ansible_tmp, 'w') as fh:
112                 fh.write("ANSIBLE_HOST_KEY_CHECKING=FALSE {}".format(
113                     ' '.join(ansible_command)))
114
115     my_env = os.environ.copy()
116     my_env['ANSIBLE_HOST_KEY_CHECKING'] = 'False'
117     logging.info("Executing playbook...this may take some time")
118     p = subprocess.Popen(ansible_command,
119                          stdin=subprocess.PIPE,
120                          stdout=subprocess.PIPE,
121                          bufsize=1,
122                          env=my_env,
123                          universal_newlines=True)
124     # read first line
125     x = p.stdout.readline()
126     # initialize task
127     task = ''
128     while x:
129         # append lines to task
130         task += x
131         # log the line and read another
132         x = p.stdout.readline()
133         # deliver the task to info when we get a blank line
134         if not x.strip():
135             task += x
136             logging.info(task.replace('\\n', '\n'))
137             task = ''
138             x = p.stdout.readline()
139     # clean up and get return code
140     p.stdout.close()
141     rc = p.wait()
142     if rc:
143         # raise errors
144         e = "Ansible playbook failed. See Ansible logs for details."
145         logging.error(e)
146         raise Exception(e)
147
148
149 def get_url_modified_date(url):
150     """
151     Returns the last modified date for an Tripleo image artifact
152     :param url: URL to examine
153     :return: datetime object of when artifact was last modified
154     """
155     try:
156         u = urllib.request.urlopen(url)
157     except urllib.error.URLError as e:
158         logging.error("Failed to fetch target url. Error: {}".format(
159             e.reason))
160         raise
161
162     metadata = u.info()
163     headers = metadata.items()
164     for header in headers:
165         if isinstance(header, tuple) and len(header) == 2:
166             if header[0] == 'Last-Modified':
167                 return datetime.datetime.strptime(header[1],
168                                                   "%a, %d %b %Y %X GMT")
169
170
171 def fetch_upstream_and_unpack(dest, url, targets, fetch=True):
172     """
173     Fetches targets from a url destination and downloads them if they are
174     newer.  Also unpacks tar files in dest dir.
175     :param dest: Directory to download and unpack files to
176     :param url: URL where target files are located
177     :param targets: List of target files to download
178     :param fetch: Whether or not to fetch latest from internet (boolean)
179     :return: None
180     """
181     os.makedirs(dest, exist_ok=True)
182     assert isinstance(targets, list)
183     for target in targets:
184         target_url = urllib.parse.urljoin(url, target)
185         target_dest = os.path.join(dest, target)
186         target_exists = os.path.isfile(target_dest)
187         if fetch:
188             download_target = True
189         elif not target_exists:
190             logging.warning("no-fetch requested but target: {} is not "
191                             "cached, will download".format(target_dest))
192             download_target = True
193         else:
194             logging.info("no-fetch requested and previous cache exists for "
195                          "target: {}.  Will skip download".format(target_dest))
196             download_target = False
197
198         if download_target:
199             logging.debug("Fetching and comparing upstream"
200                           " target: \n{}".format(target_url))
201         # Check if previous file and fetch we need to compare files to
202         # determine if download is necessary
203         if target_exists and download_target:
204             logging.debug("Previous file found: {}".format(target_dest))
205             target_url_date = get_url_modified_date(target_url)
206             if target_url_date is not None:
207                 target_dest_mtime = os.path.getmtime(target_dest)
208                 target_url_mtime = time.mktime(target_url_date.timetuple())
209                 if target_url_mtime > target_dest_mtime:
210                     logging.debug('URL target is newer than disk...will '
211                                   'download')
212                 else:
213                     logging.info('URL target does not need to be downloaded')
214                     download_target = False
215             else:
216                 logging.debug('Unable to find last modified url date')
217
218         if download_target:
219             urllib.request.urlretrieve(target_url, filename=target_dest)
220             logging.info("Target downloaded: {}".format(target))
221         if target.endswith(('.tar', 'tar.gz', 'tgz')):
222             logging.info('Unpacking tar file')
223             tar = tarfile.open(target_dest)
224             tar.extractall(path=dest)
225             tar.close()
226
227
228 def install_ansible():
229     # we only install for CentOS/Fedora for now
230     dist = distro.id()
231     if 'centos' in dist:
232         pkg_mgr = 'yum'
233     elif 'fedora' in dist:
234         pkg_mgr = 'dnf'
235     else:
236         return
237
238     # yum python module only exists for 2.x, so use subprocess
239     try:
240         subprocess.check_call([pkg_mgr, '-y', 'install', 'ansible'])
241     except subprocess.CalledProcessError:
242         logging.warning('Unable to install Ansible')
243
244
245 def internet_connectivity():
246     try:
247         urllib.request.urlopen('http://opnfv.org', timeout=3)
248         return True
249     except (urllib.request.URLError, socket.timeout):
250         logging.debug('No internet connectivity detected')
251         return False
252
253
254 def open_webpage(url, timeout=5):
255     try:
256         response = urllib.request.urlopen(url, timeout=timeout)
257         return response.read()
258     except (urllib.request.URLError, socket.timeout) as e:
259         logging.error("Unable to open URL: {}".format(url))
260         raise exc.FetchException('Unable to open URL') from e
261
262
263 def edit_tht_env(env_file, section, settings):
264     assert isinstance(settings, dict)
265     with open(env_file) as fh:
266         data = yaml.safe_load(fh)
267
268     if section not in data.keys():
269         data[section] = {}
270     for setting, value in settings.items():
271         data[section][setting] = value
272     with open(env_file, 'w') as fh:
273         yaml.safe_dump(data, fh, default_flow_style=False)
274     logging.debug("Data written to env file {}:\n{}".format(env_file, data))
275
276
277 def unique(tmp_list):
278     assert isinstance(tmp_list, list)
279     uniq_list = []
280     for x in tmp_list:
281         if x not in uniq_list:
282             uniq_list.append(x)
283     return uniq_list
284
285
286 def bash_settings_to_dict(data):
287     """
288     Parses bash settings x=y and returns dict of key, values
289     :param data: bash settings data in x=y format
290     :return: dict of keys and values
291     """
292     return dict(item.split('=') for item in data.splitlines())
293
294
295 def fetch_properties(url):
296     """
297     Downloads OPNFV properties and returns a dictionary of the key, values
298     :param url: URL of properties file
299     :return: dict of k,v for each properties
300     """
301     if bool(urllib.parse.urlparse(url).scheme):
302         logging.debug('Fetching properties from internet: {}'.format(url))
303         return bash_settings_to_dict(open_webpage(url).decode('utf-8'))
304     elif os.path.isfile(url):
305         logging.debug('Fetching properties from file: {}'.format(url))
306         with open(url, 'r') as fh:
307             data = fh.read()
308         return bash_settings_to_dict(data)
309     else:
310         logging.warning('Unable to fetch properties for: {}'.format(url))
311         raise exc.FetchException('Unable determine properties location: '
312                                  '{}'.format(url))