Remove outdated doc for A1 Adaptor
[integration.git] / test / mocks / masspnfsim / MassPnfSim.py
1 #!/usr/bin/env python3
2 import logging
3 import base64
4 from subprocess import run, CalledProcessError
5 import argparse
6 import ipaddress
7 from sys import exit
8 from os import chdir, getcwd, path, popen, kill, getuid, stat, mkdir, chmod
9 from shutil import copytree, rmtree, move
10 from json import loads, dumps
11 from yaml import load, SafeLoader, dump
12 from glob import glob
13 from time import strftime, tzname, daylight
14 from docker import from_env
15 from requests import get, codes, post
16 from requests.exceptions import MissingSchema, InvalidSchema, InvalidURL, ConnectionError, ConnectTimeout
17
18 def validate_url(url):
19     '''Helper function to perform --urlves input param validation'''
20     logger = logging.getLogger("urllib3")
21     logger.setLevel(logging.WARNING)
22     try:
23         get(url, timeout=0.001)
24     except (MissingSchema, InvalidSchema, InvalidURL):
25         raise argparse.ArgumentTypeError(f'{url} is not a valid URL')
26     except (ConnectionError, ConnectTimeout):
27         pass
28     return url
29
30 def merge_dictionaries(origin, custom):
31     '''Combine 2 dictionaries based on common keys.'''
32     return {
33             key: dict(
34                 origin.get(key, {}),
35                 **custom.get(key, {}))
36             for key in origin.keys() | custom.keys()
37         }
38
39 def validate_ip(ip):
40     '''Helper function to validate input param is a vaild IP address'''
41     try:
42         ip_valid = ipaddress.ip_address(ip)
43     except ValueError:
44         raise argparse.ArgumentTypeError(f'{ip} is not a valid IP address')
45     else:
46         return ip_valid
47
48 def get_auth_token_base64(plain):
49     '''Converts user:password to Base64.'''
50     basic_auth_plain = plain
51     basic_auth_bytes = basic_auth_plain.encode('ascii')
52     basic_auth_base64_bytes = base64.b64encode(basic_auth_bytes)
53     basic_auth_base64 = basic_auth_base64_bytes.decode('ascii')
54     return basic_auth_base64
55
56 def get_parser():
57     '''Process input arguments'''
58
59     parser = argparse.ArgumentParser()
60     subparsers = parser.add_subparsers(title='Subcommands', dest='subcommand')
61     # Build command parser
62     subparsers.add_parser('build', help='Build simulator image')
63     # Bootstrap command parser
64     parser_bootstrap = subparsers.add_parser('bootstrap', help='Bootstrap the system')
65     parser_bootstrap.add_argument('--count', help='Instance count to bootstrap', type=int, metavar='INT', default=1)
66     parser_bootstrap.add_argument('--urlves', help='URL of the VES collector', type=validate_url, metavar='URL', required=True)
67     parser_bootstrap.add_argument('--ipfileserver', help='Visible IP of the file server (SFTP/FTPS) to be included in the VES event',
68                                   type=validate_ip, metavar='IP', required=True)
69     parser_bootstrap.add_argument('--typefileserver', help='Type of the file server (SFTP/FTPS) to be included in the VES event',
70                                   type=str, choices=['sftp', 'ftps'], required=True)
71     parser_bootstrap.add_argument('--user', help='File server username', type=str, metavar='USERNAME', required=True)
72     parser_bootstrap.add_argument('--password', help='File server password', type=str, metavar='PASSWORD', required=True)
73     parser_bootstrap.add_argument('--ipstart', help='IP address range beginning', type=validate_ip, metavar='IP', required=True)
74     # Start command parser
75     parser_start = subparsers.add_parser('start', help='Start instances')
76     parser_start.add_argument('--count', help='Instance count to start', type=int, metavar='INT', default=0)
77     # Stop command parser
78     parser_stop = subparsers.add_parser('stop', help='Stop instances')
79     parser_stop.add_argument('--count', help='Instance count to stop', type=int, metavar='INT', default=0)
80     # Trigger command parser
81     parser_trigger = subparsers.add_parser('trigger', help='Trigger one single VES event from each simulator')
82     parser_trigger.add_argument('--count', help='Instance count to trigger', type=int, metavar='INT', default=0)
83     parser_trigger.add_argument('--user', help='VES auth username', type=str, metavar='USERNAME')
84     parser_trigger.add_argument('--password', help='VES auth password', type=str, metavar='PASSWORD')
85     # Stop-simulator command parser
86     parser_stopsimulator = subparsers.add_parser('stop_simulator', help='Stop sending PNF registration messages')
87     parser_stopsimulator.add_argument('--count', help='Instance count to stop', type=int, metavar='INT', default=0)
88     # Trigger-custom command parser
89     parser_triggerstart = subparsers.add_parser('trigger_custom', help='Trigger one single VES event from specific simulators')
90     parser_triggerstart.add_argument('--triggerstart', help='First simulator id to trigger', type=int,
91                                      metavar='INT', required=True)
92     parser_triggerstart.add_argument('--triggerend', help='Last simulator id to trigger', type=int,
93                                      metavar='INT', required=True)
94     parser_triggerstart.add_argument('--user', help='VES auth username', type=str, metavar='USERNAME')
95     parser_triggerstart.add_argument('--password', help='VES auth password', type=str, metavar='PASSWORD')
96     parser_triggerstart.add_argument('--data', help='Custom data to override default values', type=dict, metavar='DATA')
97     # Status command parser
98     parser_status = subparsers.add_parser('status', help='Status')
99     parser_status.add_argument('--count', help='Instance count to show status for', type=int, metavar='INT', default=0)
100     # Clean command parser
101     subparsers.add_parser('clean', help='Clean work-dirs')
102     # General options parser
103     parser.add_argument('--verbose', help='Verbosity level', choices=['info', 'debug'],
104                         type=str, default='info')
105     return parser
106
107 class MassPnfSim:
108
109     # MassPnfSim class actions decorator
110     class _MassPnfSim_Decorators:
111         @staticmethod
112         def validate_subcommand(method):
113             def wrapper(self, args): # pylint: disable=W0613
114                 # Validate 'trigger_custom' subcommand options
115                 if self.args.subcommand == 'trigger_custom':
116                     if (self.args.triggerend + 1) > self._enum_sim_instances():
117                         self.logger.error('--triggerend value greater than existing instance count.')
118                         exit(1)
119
120                 # Validate --count option for subcommands that support it
121                 if self.args.subcommand in ['start', 'stop', 'trigger', 'status', 'stop_simulator']:
122                     if self.args.count > self._enum_sim_instances():
123                         self.logger.error('--count value greater that existing instance count')
124                         exit(1)
125                     if not self._enum_sim_instances():
126                         self.logger.error('No bootstrapped instance found')
127                         exit(1)
128
129                 # Validate 'bootstrap' subcommand
130                 if (self.args.subcommand == 'bootstrap') and self._enum_sim_instances():
131                     self.logger.error('Bootstrapped instances detected, not overwiriting, clean first')
132                     exit(1)
133                 method(self, args)
134             return wrapper
135
136         @staticmethod
137         def substitute_instance_args(method):
138             def wrapper(self, args):
139                 self.args = args
140                 method(self, args)
141             return wrapper
142
143     log_lvl = logging.INFO
144     sim_compose_template = 'docker-compose-template.yml'
145     sim_vsftpd_template = 'config/vsftpd_ssl-TEMPLATE.conf'
146     sim_vsftpd_config = 'config/vsftpd_ssl.conf'
147     sim_sftp_script = 'fix-sftp-perms.sh'
148     sim_sftp_script_template = 'fix-sftp-perms-template.sh'
149     sim_config = 'config/config.yml'
150     sim_msg_config = 'config/config.json'
151     sim_port = 5000
152     sim_base_url = 'http://{}:' + str(sim_port) + '/simulator'
153     sim_start_url = sim_base_url + '/start'
154     sim_status_url = sim_base_url + '/status'
155     sim_stop_url = sim_base_url + '/stop'
156     sim_container_name = 'pnf-simulator'
157     rop_script_name = 'ROP_file_creator.sh'
158
159     def __init__(self):
160         self.logger = logging.getLogger(__name__)
161         self.logger.setLevel(self.log_lvl)
162         self.sim_dirname_pattern = "pnf-sim-lw-"
163         self.mvn_build_cmd = 'mvn clean package docker:build -Dcheckstyle.skip'
164         self.docker_compose_status_cmd = 'docker-compose ps'
165
166     def _run_cmd(self, cmd, dir_context='.'):
167         old_pwd = getcwd()
168         try:
169             chdir(dir_context)
170             self.logger.debug(f'_run_cmd: Current direcotry: {getcwd()}')
171             self.logger.debug(f'_run_cmd: Command string: {cmd}')
172             run(cmd, check=True, shell=True)
173             chdir(old_pwd)
174         except FileNotFoundError:
175             self.logger.error(f"Directory {dir_context} not found")
176         except CalledProcessError as e:
177             exit(e.returncode)
178
179     def _enum_sim_instances(self):
180         '''Helper method that returns bootstraped simulator instances count'''
181         return len(glob(f"{self.sim_dirname_pattern}[0-9]*"))
182
183     def _get_sim_instance_data(self, instance_id):
184         '''Helper method that returns specific instance data'''
185         oldpwd = getcwd()
186         chdir(f"{self.sim_dirname_pattern}{instance_id}")
187         with open(self.sim_config) as cfg:
188             yml = load(cfg, Loader=SafeLoader)
189         chdir(oldpwd)
190         return yml['ippnfsim']
191
192     def _get_docker_containers(self):
193         '''Returns a list containing 'name' attribute of running docker containers'''
194         dc = from_env()
195         containers = []
196         for container in dc.containers.list():
197             containers.append(container.attrs['Name'][1:])
198         return containers
199
200     def _get_iter_range(self):
201         '''Helper routine to get the iteration range
202         for the lifecycle commands'''
203         if hasattr(self.args, 'count'):
204             if not self.args.count:
205                 return [self._enum_sim_instances()]
206             else:
207                 return [self.args.count]
208         elif hasattr(self.args, 'triggerstart'):
209             return [self.args.triggerstart, self.args.triggerend + 1]
210         else:
211             return [self._enum_sim_instances()]
212
213     def _archive_logs(self, sim_dir):
214         '''Helper function to archive simulator logs or create the log dir'''
215         old_pwd = getcwd()
216         try:
217             chdir(sim_dir)
218             if path.isdir('logs'):
219                 arch_dir = f"logs/archive_{strftime('%Y-%m-%d_%T')}"
220                 mkdir(arch_dir)
221                 self.logger.debug(f'Created {arch_dir}')
222                 # Collect file list to move
223                 self.logger.debug('Archiving log files')
224                 for fpattern in ['*.log', '*.xml']:
225                     for f in glob('logs/' + fpattern):
226                         # Move files from list to arch dir
227                         move(f, arch_dir)
228                         self.logger.debug(f'Moving {f} to {arch_dir}')
229             else:
230                 mkdir('logs')
231                 self.logger.debug("Logs dir didn't exist, created")
232             chdir(old_pwd)
233         except FileNotFoundError:
234             self.logger.error(f"Directory {sim_dir} not found")
235
236     def _generate_pnf_sim_config(self, i, port_sftp, port_ftps, pnf_sim_ip):
237         '''Writes a yaml formatted configuration file for Java simulator app'''
238         yml = {}
239         yml['urlves'] = self.args.urlves
240         yml['urlsftp'] = f'sftp://{self.args.user}:{self.args.password}@{self.args.ipfileserver}:{port_sftp}'
241         yml['urlftps'] = f'ftps://{self.args.user}:{self.args.password}@{self.args.ipfileserver}:{port_ftps}'
242         yml['ippnfsim'] = pnf_sim_ip
243         yml['typefileserver'] = self.args.typefileserver
244         self.logger.debug(f'Generated simulator config:\n{dump(yml)}')
245         with open(f'{self.sim_dirname_pattern}{i}/{self.sim_config}', 'w') as fout:
246             fout.write(dump(yml))
247
248     def _generate_config_file(self, source, dest, **kwargs):
249         '''Helper private method to generate a file based on a template'''
250         old_pwd = getcwd()
251         chdir(self.sim_dirname_pattern + str(kwargs['I']))
252         # Read the template file
253         with open(source, 'r') as f:
254             template = f.read()
255         # Replace all occurences of env like variable with it's
256         # relevant value from a corresponding key form kwargs
257         for (k,v) in kwargs.items():
258             template = template.replace('${' + k + '}', str(v))
259         with open(dest, 'w') as f:
260             f.write(template)
261         chdir(old_pwd)
262
263     @_MassPnfSim_Decorators.substitute_instance_args
264     @_MassPnfSim_Decorators.validate_subcommand
265     def bootstrap(self, args): # pylint: disable=W0613
266         self.logger.info("Bootstrapping PNF instances")
267
268         start_port = 2000
269         ftps_pasv_port_start = 8000
270         ftps_pasv_port_num_of_ports = 10
271
272         ftps_pasv_port_end = ftps_pasv_port_start + ftps_pasv_port_num_of_ports
273
274         for i in range(self.args.count):
275             self.logger.info(f"PNF simulator instance: {i}")
276
277             # The IP ranges are in distance of 16 compared to each other.
278             # This is matching the /28 subnet mask used in the dockerfile inside.
279             instance_ip_offset = i * 16
280             ip_properties = [
281                       'subnet',
282                       'gw',
283                       'PnfSim',
284                       'ftps',
285                       'sftp'
286                     ]
287
288             ip_offset = 0
289             ip = {}
290             for prop in ip_properties:
291                 ip.update({prop: str(self.args.ipstart + ip_offset + instance_ip_offset)})
292                 ip_offset += 1
293
294             self.logger.debug(f'Instance #{i} properties:\n {dumps(ip, indent=4)}')
295
296             PortSftp = start_port + 1
297             PortFtps = start_port + 2
298             start_port += 2
299
300             self.logger.info(f'\tCreating {self.sim_dirname_pattern}{i}')
301             copytree('pnf-sim-lightweight', f'{self.sim_dirname_pattern}{i}')
302             self.logger.info(f"\tCreating instance #{i} configuration ")
303             self._generate_pnf_sim_config(i, PortSftp, PortFtps, ip['PnfSim'])
304             # generate docker-compose for the simulator instance
305             self._generate_config_file(self.sim_compose_template, 'docker-compose.yml',
306                                        IPGW = ip['gw'], IPSUBNET = ip['subnet'],
307                                        I = i, IPPNFSIM = ip['PnfSim'],
308                                        PORTSFTP = str(PortSftp),
309                                        PORTFTPS = str(PortFtps),
310                                        IPFTPS = ip['ftps'], IPSFTP = ip['sftp'],
311                                        FTPS_PASV_MIN = str(ftps_pasv_port_start),
312                                        FTPS_PASV_MAX = str(ftps_pasv_port_end),
313                                        TIMEZONE = tzname[daylight],
314                                        FILESERV_USER = self.args.user,
315                                        FILESERV_PASS = self.args.password)
316             # generate vsftpd config file for the simulator instance
317             self._generate_config_file(self.sim_vsftpd_template, self.sim_vsftpd_config,
318                                        I = i,
319                                        FTPS_PASV_MIN = str(ftps_pasv_port_start),
320                                        FTPS_PASV_MAX = str(ftps_pasv_port_end),
321                                        IPFILESERVER = str(self.args.ipfileserver))
322             # generate sftp permission fix script
323             self._generate_config_file(self.sim_sftp_script_template, self.sim_sftp_script,
324                                        I = i, FILESERV_USER = self.args.user)
325             chmod(f'{self.sim_dirname_pattern}{i}/{self.sim_sftp_script}', 0o755)
326
327             ftps_pasv_port_start += ftps_pasv_port_num_of_ports + 1
328             ftps_pasv_port_end += ftps_pasv_port_num_of_ports + 1
329
330             # ugly hack to chown vsftpd config file to root
331             if getuid():
332                 self._run_cmd(f'sudo chown root {self.sim_vsftpd_config}', f'{self.sim_dirname_pattern}{i}')
333                 self.logger.debug(f"vsftpd config file owner UID: {stat(self.sim_dirname_pattern + str(i) + '/' + self.sim_vsftpd_config).st_uid}")
334
335             self.logger.info(f'Done setting up instance #{i}')
336
337     @_MassPnfSim_Decorators.substitute_instance_args
338     def build(self, args): # pylint: disable=W0613
339         self.logger.info("Building simulator image")
340         if path.isfile('pnf-sim-lightweight/pom.xml'):
341             self._run_cmd(self.mvn_build_cmd, 'pnf-sim-lightweight')
342         else:
343             self.logger.error('POM file was not found, Maven cannot run')
344             exit(1)
345
346     @_MassPnfSim_Decorators.substitute_instance_args
347     def clean(self, args): # pylint: disable=W0613
348         self.logger.info('Cleaning simulators workdirs')
349         for sim_id in range(self._enum_sim_instances()):
350             rmtree(f"{self.sim_dirname_pattern}{sim_id}")
351
352     @_MassPnfSim_Decorators.substitute_instance_args
353     @_MassPnfSim_Decorators.validate_subcommand
354     def start(self, args): # pylint: disable=W0613
355         for i in range(*self._get_iter_range()):
356             # Start measurements file generator if not running
357             rop_running = False
358             for ps_line in iter(popen(f'ps --no-headers -C {self.rop_script_name} -o pid,cmd').readline, ''):
359                 # try getting ROP script pid
360                 try:
361                     ps_line_arr = ps_line.split()
362                     assert self.rop_script_name in ps_line_arr[2]
363                     assert ps_line_arr[3] == str(i)
364                 except AssertionError:
365                     pass
366                 else:
367                     self.logger.warning(f'3GPP measurements file generator for instance {i} is already running')
368                     rop_running = True
369             if not rop_running:
370                 self._run_cmd(f'./ROP_file_creator.sh {i} &', f"{self.sim_dirname_pattern}{i}")
371                 self.logger.info(f'ROP_file_creator.sh {i} successfully started')
372             # If container is not running
373             if f"{self.sim_container_name}-{i}" not in self._get_docker_containers():
374                 self.logger.info(f'Starting {self.sim_dirname_pattern}{i} instance:')
375                 self.logger.info(f' PNF-Sim IP: {self._get_sim_instance_data(i)}')
376                 #Move logs to archive
377                 self._archive_logs(self.sim_dirname_pattern + str(i))
378                 self.logger.info(' Starting simulator containers using netconf model specified in config/netconf.env')
379                 self._run_cmd('docker-compose up -d', self.sim_dirname_pattern + str(i))
380             else:
381                 self.logger.warning(f'Instance {self.sim_dirname_pattern}{i} containers are already up')
382
383     @_MassPnfSim_Decorators.substitute_instance_args
384     @_MassPnfSim_Decorators.validate_subcommand
385     def status(self, args): # pylint: disable=W0613
386         for i in range(*self._get_iter_range()):
387             self.logger.info(f'Getting {self.sim_dirname_pattern}{i} instance status:')
388             if f"{self.sim_container_name}-{i}" in self._get_docker_containers():
389                 try:
390                     sim_ip = self._get_sim_instance_data(i)
391                     self.logger.info(f' PNF-Sim IP: {sim_ip}')
392                     self._run_cmd(self.docker_compose_status_cmd, f"{self.sim_dirname_pattern}{i}")
393                     sim_response = get('{}'.format(self.sim_status_url).format(sim_ip))
394                     if sim_response.status_code == codes.ok:
395                         self.logger.info(sim_response.text)
396                     else:
397                         self.logger.error(f'Simulator request returned http code {sim_response.status_code}')
398                 except KeyError:
399                     self.logger.error(f'Unable to get sim instance IP from {self.sim_config}')
400             else:
401                 self.logger.info(' Simulator containers are down')
402
403     @_MassPnfSim_Decorators.substitute_instance_args
404     @_MassPnfSim_Decorators.validate_subcommand
405     def stop(self, args): # pylint: disable=W0613
406         for i in range(*self._get_iter_range()):
407             self.logger.info(f'Stopping {self.sim_dirname_pattern}{i} instance:')
408             self.logger.info(f' PNF-Sim IP: {self._get_sim_instance_data(i)}')
409             # attempt killing ROP script
410             rop_pid = []
411             for ps_line in iter(popen(f'ps --no-headers -C {self.rop_script_name} -o pid,cmd').readline, ''):
412                 # try getting ROP script pid
413                 try:
414                     ps_line_arr = ps_line.split()
415                     assert self.rop_script_name in ps_line_arr[2]
416                     assert ps_line_arr[3] == str(i)
417                     rop_pid = ps_line_arr[0]
418                 except AssertionError:
419                     pass
420                 else:
421                     # get rop script childs, kill ROP script and all childs
422                     childs = popen(f'pgrep -P {rop_pid}').read().split()
423                     for pid in [rop_pid] + childs:
424                         kill(int(pid), 15)
425                     self.logger.info(f' ROP_file_creator.sh {i} successfully killed')
426             if not rop_pid:
427                 # no process found
428                 self.logger.warning(f' ROP_file_creator.sh {i} already not running')
429             # try tearing down docker-compose application
430             if f"{self.sim_container_name}-{i}" in self._get_docker_containers():
431                 self._run_cmd('docker-compose down', self.sim_dirname_pattern + str(i))
432                 self._run_cmd('docker-compose rm', self.sim_dirname_pattern + str(i))
433             else:
434                 self.logger.warning(" Simulator containers are already down")
435
436     @_MassPnfSim_Decorators.substitute_instance_args
437     @_MassPnfSim_Decorators.validate_subcommand
438     def trigger(self, args): # pylint: disable=W0613
439         self.logger.info("Triggering VES sending:")
440
441         for i in range(*self._get_iter_range()):
442
443             sim_ip = self._get_sim_instance_data(i)
444             self.logger.info(f'Triggering {self.sim_dirname_pattern}{i} instance:')
445             self.logger.info(f' PNF-Sim IP: {sim_ip}')
446
447             # create a Basic auth token
448             plaintext_auth = f"{args.user}:{args.password}"
449             basic_auth_base64 = get_auth_token_base64(plaintext_auth)
450             basic_auth_token = f"Basic {basic_auth_base64}"
451             self.logger.info((basic_auth_base64))
452
453             # setup req headers
454             req_headers = {
455                     "Content-Type": "application/json",
456                     "X-ONAP-RequestID": "123",
457                     "X-InvocationID": "456",
458                     "Authorization": basic_auth_token
459                 }
460             self.logger.debug(f' Request headers: {req_headers}')
461
462             try:
463
464                 # get payload for the request
465                 with open(f'{self.sim_dirname_pattern}{i}/{self.sim_msg_config}') as data:
466
467                     json_data = loads(data.read())
468                     try:
469                         json_data = merge_dictionaries(json_data, args.data)
470                     except AttributeError:
471                         self.logger.debug('The request will be sent without customization.')
472
473                     self.logger.debug(f' JSON payload for the simulator:\n{json_data}')
474
475                     # make a http request to the simulator
476                     sim_response = post('{}'.format(self.sim_start_url).format(sim_ip), headers=req_headers, json=json_data)
477
478                     if sim_response.status_code == codes.ok:
479                         self.logger.info(' Simulator response: ' + sim_response.text)
480                     else:
481                         self.logger.warning(' Simulator response ' + sim_response.text)
482
483             except TypeError:
484                 self.logger.error(f' Could not load JSON data from {self.sim_dirname_pattern}{i}/{self.sim_msg_config}')
485
486     # Make the 'trigger_custom' an alias to the 'trigger' method
487     trigger_custom = trigger
488
489     @_MassPnfSim_Decorators.substitute_instance_args
490     @_MassPnfSim_Decorators.validate_subcommand
491     def stop_simulator(self, args): # pylint: disable=W0613
492         self.logger.info("Stopping sending PNF registration messages:")
493         for i in range(*self._get_iter_range()):
494             sim_ip = self._get_sim_instance_data(i)
495             self.logger.info(f'Stopping {self.sim_dirname_pattern}{i} instance:')
496             self.logger.info(f' PNF-Sim IP: {sim_ip}')
497             sim_response = post('{}'.format(self.sim_stop_url).format(sim_ip))
498             if sim_response.status_code == codes.ok:
499                 self.logger.info(' Simulator response: ' + sim_response.text)
500             else:
501                 self.logger.warning(' Simulator response ' + sim_response.text)