Setup and start docker-compose simulator app directly in Python module
[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
8 from shutil import copytree, rmtree, move
9 from json import loads, dumps
10 from yaml import load, SafeLoader
11 from glob import glob
12 from time import strftime
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('--ipstart', help='IP address range beginning', type=validate_ip, metavar='IP', required=True)
54     # Start command parser
55     parser_start = subparsers.add_parser('start', help='Start instances')
56     parser_start.add_argument('--count', help='Instance count to start', type=int, metavar='INT', default=0)
57     # Stop command parser
58     parser_stop = subparsers.add_parser('stop', help='Stop instances')
59     parser_stop.add_argument('--count', help='Instance count to stop', type=int, metavar='INT', default=0)
60     # Trigger command parser
61     parser_trigger = subparsers.add_parser('trigger', help='Trigger one single VES event from each simulator')
62     parser_trigger.add_argument('--count', help='Instance count to trigger', type=int, metavar='INT', default=0)
63     # Stop-simulator command parser
64     parser_stopsimulator = subparsers.add_parser('stop_simulator', help='Stop sending PNF registration messages')
65     parser_stopsimulator.add_argument('--count', help='Instance count to stop', type=int, metavar='INT', default=0)
66     # Trigger-custom command parser
67     parser_triggerstart = subparsers.add_parser('trigger_custom', help='Trigger one single VES event from specific simulators')
68     parser_triggerstart.add_argument('--triggerstart', help='First simulator id to trigger', type=int,
69                                      metavar='INT', required=True)
70     parser_triggerstart.add_argument('--triggerend', help='Last simulator id to trigger', type=int,
71                                      metavar='INT', required=True)
72     # Status command parser
73     parser_status = subparsers.add_parser('status', help='Status')
74     parser_status.add_argument('--count', help='Instance count to show status for', type=int, metavar='INT', default=0)
75     # Clean command parser
76     subparsers.add_parser('clean', help='Clean work-dirs')
77     # General options parser
78     parser.add_argument('--verbose', help='Verbosity level', choices=['info', 'debug'],
79                         type=str, default='info')
80     return parser
81
82 class MassPnfSim:
83
84     # MassPnfSim class actions decorator
85     class _MassPnfSim_Decorators:
86
87         @staticmethod
88         def do_action(action_string, cmd):
89             def action_decorator(method):
90                 def action_wrap(self):
91                     # Alter looping range if action is 'tigger_custom'
92                     if method.__name__ == 'trigger_custom':
93                         iter_range = [self.args.triggerstart, self.args.triggerend+1]
94                     else:
95                         if not self.args.count:
96                             # If no instance count set explicitly via --count
97                             # option
98                             iter_range = [self.existing_sim_instances]
99                         else:
100                             iter_range = [self.args.count]
101                     method(self)
102                     for i in range(*iter_range):
103                         self.logger.info(f'{action_string} {self.sim_dirname_pattern}{i} instance:')
104                         self._run_cmd(cmd, f"{self.sim_dirname_pattern}{i}")
105                 return action_wrap
106             return action_decorator
107
108     log_lvl = logging.INFO
109     sim_config = 'config/config.yml'
110     sim_msg_config = 'config/config.json'
111     sim_port = 5000
112     sim_base_url = 'http://{}:' + str(sim_port) + '/simulator'
113     sim_start_url = sim_base_url + '/start'
114     sim_status_url = sim_base_url + '/status'
115     sim_stop_url = sim_base_url + '/stop'
116     sim_container_name = 'pnf-simulator'
117     rop_script_name = 'ROP_file_creator.sh'
118
119     def __init__(self, args):
120         self.args = args
121         self.logger = logging.getLogger(__name__)
122         self.logger.setLevel(self.log_lvl)
123         self.sim_dirname_pattern = "pnf-sim-lw-"
124         self.mvn_build_cmd = 'mvn clean package docker:build -Dcheckstyle.skip'
125         self.docker_compose_status_cmd = 'docker-compose ps'
126         self.existing_sim_instances = self._enum_sim_instances()
127
128         # Validate 'trigger_custom' subcommand options
129         if self.args.subcommand == 'trigger_custom':
130             if (self.args.triggerend + 1) > self.existing_sim_instances:
131                 self.logger.error('--triggerend value greater than existing instance count.')
132                 exit(1)
133
134         # Validate --count option for subcommands that support it
135         if self.args.subcommand in ['start', 'stop', 'trigger', 'status', 'stop_simulator']:
136             if self.args.count > self.existing_sim_instances:
137                 self.logger.error('--count value greater that existing instance count')
138                 exit(1)
139             if not self.existing_sim_instances:
140                 self.logger.error('No bootstrapped instance found')
141                 exit(1)
142
143         # Validate 'bootstrap' subcommand
144         if (self.args.subcommand == 'bootstrap') and self.existing_sim_instances:
145             self.logger.error('Bootstrapped instances detected, not overwiriting, clean first')
146             exit(1)
147
148     def _run_cmd(self, cmd, dir_context='.'):
149         old_pwd = getcwd()
150         try:
151             chdir(dir_context)
152             self.logger.debug(f'_run_cmd: Current direcotry: {getcwd()}')
153             self.logger.debug(f'_run_cmd: Command string: {cmd}')
154             run(cmd, check=True, shell=True)
155             chdir(old_pwd)
156         except FileNotFoundError:
157             self.logger.error(f"Directory {dir_context} not found")
158         except CalledProcessError as e:
159             exit(e.returncode)
160
161     def _enum_sim_instances(self):
162         '''Helper method that returns bootstraped simulator instances count'''
163         return len(glob(f"{self.sim_dirname_pattern}[0-9]*"))
164
165     def _get_sim_instance_data(self, instance_id):
166         '''Helper method that returns specific instance data'''
167         oldpwd = getcwd()
168         chdir(f"{self.sim_dirname_pattern}{instance_id}")
169         with open(self.sim_config) as cfg:
170             yml = load(cfg, Loader=SafeLoader)
171         chdir(oldpwd)
172         return yml['ippnfsim']
173
174     def _get_docker_containers(self):
175         '''Returns a list containing 'name' attribute of running docker containers'''
176         dc = from_env()
177         containers = []
178         for container in dc.containers.list():
179             containers.append(container.attrs['Name'][1:])
180         return containers
181
182     def _get_iter_range(self):
183         '''Helper routine to get the iteration range
184         for the lifecycle commands'''
185         if hasattr(self.args, 'count'):
186             if not self.args.count:
187                 return [self.existing_sim_instances]
188             else:
189                 return [self.args.count]
190         elif hasattr(self.args, 'triggerstart'):
191             return [self.args.triggerstart, self.args.triggerend + 1]
192         else:
193             return [self.existing_sim_instances]
194
195     def _archive_logs(self, sim_dir):
196         '''Helper function to archive simulator logs or create the log dir'''
197         old_pwd = getcwd()
198         try:
199             chdir(sim_dir)
200             if path.isdir('logs'):
201                 arch_dir = f"logs/archive_{strftime('%Y-%m-%d_%T')}"
202                 mkdir(arch_dir)
203                 self.logger.debug(f'Created {arch_dir}')
204                 # Collect file list to move
205                 self.logger.debug('Archiving log files')
206                 for fpattern in ['*.log', '*.xml']:
207                     for f in glob('logs/' + fpattern):
208                         # Move files from list to arch dir
209                         move(f, arch_dir)
210                         self.logger.debug(f'Moving {f} to {arch_dir}')
211             else:
212                 mkdir('logs')
213                 self.logger.debug("Logs dir didn't exist, created")
214             chdir(old_pwd)
215         except FileNotFoundError:
216             self.logger.error(f"Directory {sim_dir} not found")
217
218     def bootstrap(self):
219         self.logger.info("Bootstrapping PNF instances")
220
221         start_port = 2000
222         ftps_pasv_port_start = 8000
223         ftps_pasv_port_num_of_ports = 10
224
225         ftps_pasv_port_end = ftps_pasv_port_start + ftps_pasv_port_num_of_ports
226
227         for i in range(self.args.count):
228             self.logger.info(f"PNF simulator instance: {i}")
229
230             # The IP ranges are in distance of 16 compared to each other.
231             # This is matching the /28 subnet mask used in the dockerfile inside.
232             instance_ip_offset = i * 16
233             ip_properties = [
234                       'subnet',
235                       'gw',
236                       'PnfSim',
237                       'ftps',
238                       'sftp'
239                     ]
240
241             ip_offset = 0
242             ip = {}
243             for prop in ip_properties:
244                 ip.update({prop: str(self.args.ipstart + ip_offset + instance_ip_offset)})
245                 ip_offset += 1
246
247             self.logger.debug(f'Instance #{i} properties:\n {dumps(ip, indent=4)}')
248
249             PortSftp = start_port + 1
250             PortFtps = start_port + 2
251             start_port += 2
252
253             self.logger.info(f'\tCreating {self.sim_dirname_pattern}{i}')
254             copytree('pnf-sim-lightweight', f'{self.sim_dirname_pattern}{i}')
255
256             composercmd = " ".join([
257                     "./simulator.sh compose",
258                     ip['gw'],
259                     ip['subnet'],
260                     str(i),
261                     self.args.urlves,
262                     ip['PnfSim'],
263                     str(self.args.ipfileserver),
264                     self.args.typefileserver,
265                     str(PortSftp),
266                     str(PortFtps),
267                     ip['ftps'],
268                     ip['sftp'],
269                     str(ftps_pasv_port_start),
270                     str(ftps_pasv_port_end)
271                 ])
272             self.logger.debug(f"Script cmdline: {composercmd}")
273             self.logger.info(f"\tCreating instance #{i} configuration ")
274             self._run_cmd(composercmd, f"{self.sim_dirname_pattern}{i}")
275
276             ftps_pasv_port_start += ftps_pasv_port_num_of_ports + 1
277             ftps_pasv_port_end += ftps_pasv_port_num_of_ports + 1
278
279             # ugly hack to chown vsftpd config file to root
280             if getuid():
281                 self._run_cmd('sudo chown root config/vsftpd_ssl.conf', f'{self.sim_dirname_pattern}{i}')
282                 self.logger.debug(f"vsftpd_ssl.conf file owner UID: {stat(self.sim_dirname_pattern + str(i) + '/config/vsftpd_ssl.conf').st_uid}")
283
284             self.logger.info(f'Done setting up instance #{i}')
285
286     def build(self):
287         self.logger.info("Building simulator image")
288         if path.isfile('pnf-sim-lightweight/pom.xml'):
289             self._run_cmd(self.mvn_build_cmd, 'pnf-sim-lightweight')
290         else:
291             self.logger.error('POM file was not found, Maven cannot run')
292             exit(1)
293
294     def clean(self):
295         self.logger.info('Cleaning simulators workdirs')
296         for sim_id in range(self.existing_sim_instances):
297             rmtree(f"{self.sim_dirname_pattern}{sim_id}")
298
299     def start(self):
300         for i in range(*self._get_iter_range()):
301             # If container is not running
302             if f"{self.sim_container_name}-{i}" not in self._get_docker_containers():
303                 self.logger.info(f'Starting {self.sim_dirname_pattern}{i} instance:')
304                 self.logger.info(f' PNF-Sim IP: {self._get_sim_instance_data(i)}')
305                 #Move logs to archive
306                 self._archive_logs(self.sim_dirname_pattern + str(i))
307                 self.logger.info(' Starting simulator containers using netconf model specified in config/netconf.env')
308                 self._run_cmd('docker-compose up -d', self.sim_dirname_pattern + str(i))
309             else:
310                 self.logger.warning(f'Instance {self.sim_dirname_pattern}{i} containers are already up')
311
312     def status(self):
313         for i in range(*self._get_iter_range()):
314             self.logger.info(f'Getting {self.sim_dirname_pattern}{i} instance status:')
315             if f"{self.sim_container_name}-{i}" in self._get_docker_containers():
316                 try:
317                     sim_ip = self._get_sim_instance_data(i)
318                     self.logger.info(f' PNF-Sim IP: {sim_ip}')
319                     self._run_cmd(self.docker_compose_status_cmd, f"{self.sim_dirname_pattern}{i}")
320                     sim_response = get('{}'.format(self.sim_status_url).format(sim_ip))
321                     if sim_response.status_code == codes.ok:
322                         self.logger.info(sim_response.text)
323                     else:
324                         self.logger.error(f'Simulator request returned http code {sim_response.status_code}')
325                 except KeyError:
326                     self.logger.error(f'Unable to get sim instance IP from {self.sim_config}')
327             else:
328                 self.logger.info(' Simulator containers are down')
329
330     def stop(self):
331         for i in range(*self._get_iter_range()):
332             self.logger.info(f'Stopping {self.sim_dirname_pattern}{i} instance:')
333             self.logger.info(f' PNF-Sim IP: {self._get_sim_instance_data(i)}')
334             # attempt killing ROP script
335             rop_pid = []
336             for ps_line in iter(popen(f'ps --no-headers -C {self.rop_script_name} -o pid,cmd').readline, ''):
337                 # try getting ROP script pid
338                 try:
339                     ps_line_arr = ps_line.split()
340                     assert self.rop_script_name in ps_line_arr[2]
341                     assert ps_line_arr[3] == str(i)
342                     rop_pid = ps_line_arr[0]
343                 except AssertionError:
344                     pass
345                 else:
346                     # get rop script childs, kill ROP script and all childs
347                     childs = popen(f'pgrep -P {rop_pid}').read().split()
348                     for pid in [rop_pid] + childs:
349                         kill(int(pid), 15)
350                     self.logger.info(f' ROP_file_creator.sh {i} successfully killed')
351             if not rop_pid:
352                 # no process found
353                 self.logger.warning(f' ROP_file_creator.sh {i} already not running')
354             # try tearing down docker-compose application
355             if f"{self.sim_container_name}-{i}" in self._get_docker_containers():
356                 self._run_cmd('docker-compose down', self.sim_dirname_pattern + str(i))
357                 self._run_cmd('docker-compose rm', self.sim_dirname_pattern + str(i))
358             else:
359                 self.logger.warning(" Simulator containers are already down")
360
361     def trigger(self):
362         self.logger.info("Triggering VES sending:")
363         for i in range(*self._get_iter_range()):
364             sim_ip = self._get_sim_instance_data(i)
365             self.logger.info(f'Triggering {self.sim_dirname_pattern}{i} instance:')
366             self.logger.info(f' PNF-Sim IP: {sim_ip}')
367             # setup req headers
368             req_headers = {
369                     "Content-Type": "application/json",
370                     "X-ONAP-RequestID": "123",
371                     "X-InvocationID": "456"
372                 }
373             self.logger.debug(f' Request headers: {req_headers}')
374             try:
375                 # get payload for the request
376                 with open(f'{self.sim_dirname_pattern}{i}/{self.sim_msg_config}') as data:
377                     json_data = loads(data.read())
378                     self.logger.debug(f' JSON payload for the simulator:\n{json_data}')
379                     # make a http request to the simulator
380                     sim_response = post('{}'.format(self.sim_start_url).format(sim_ip), headers=req_headers, json=json_data)
381                     if sim_response.status_code == codes.ok:
382                         self.logger.info(' Simulator response: ' + sim_response.text)
383                     else:
384                         self.logger.warning(' Simulator response ' + sim_response.text)
385             except TypeError:
386                 self.logger.error(f' Could not load JSON data from {self.sim_dirname_pattern}{i}/{self.sim_msg_config}')
387
388     # Make the 'trigger_custom' an alias to the 'trigger' method
389     trigger_custom = trigger
390
391     def stop_simulator(self):
392         self.logger.info("Stopping sending PNF registration messages:")
393         for i in range(*self._get_iter_range()):
394             sim_ip = self._get_sim_instance_data(i)
395             self.logger.info(f'Stopping {self.sim_dirname_pattern}{i} instance:')
396             self.logger.info(f' PNF-Sim IP: {sim_ip}')
397             sim_response = post('{}'.format(self.sim_stop_url).format(sim_ip))
398             if sim_response.status_code == codes.ok:
399                 self.logger.info(' Simulator response: ' + sim_response.text)
400             else:
401                 self.logger.warning(' Simulator response ' + sim_response.text)