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