Drop shell debug mode in command wrapper method
[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
8 from shutil import copytree, rmtree
9 from json import loads, dumps
10 from yaml import load, SafeLoader
11 from glob import glob
12 from docker import from_env
13 from requests import get, codes, post
14 from requests.exceptions import MissingSchema, InvalidSchema, InvalidURL, ConnectionError, ConnectTimeout
15
16 def validate_url(url):
17     '''Helper function to perform --urlves input param validation'''
18     logger = logging.getLogger("urllib3")
19     logger.setLevel(logging.WARNING)
20     try:
21         get(url, timeout=0.001)
22     except (MissingSchema, InvalidSchema, InvalidURL):
23         raise argparse.ArgumentTypeError(f'{url} is not a valid URL')
24     except (ConnectionError, ConnectTimeout):
25         pass
26     return url
27
28 def validate_ip(ip):
29     '''Helper function to validate input param is a vaild IP address'''
30     try:
31         ip_valid = ipaddress.ip_address(ip)
32     except ValueError:
33         raise argparse.ArgumentTypeError(f'{ip} is not a valid IP address')
34     else:
35         return ip_valid
36
37 def get_parser():
38     '''Process input arguments'''
39
40     parser = argparse.ArgumentParser()
41     subparsers = parser.add_subparsers(title='Subcommands', dest='subcommand')
42     # Build command parser
43     subparsers.add_parser('build', help='Build simulator image')
44     # Bootstrap command parser
45     parser_bootstrap = subparsers.add_parser('bootstrap', help='Bootstrap the system')
46     parser_bootstrap.add_argument('--count', help='Instance count to bootstrap', type=int, metavar='INT', default=1)
47     parser_bootstrap.add_argument('--urlves', help='URL of the VES collector', type=validate_url, metavar='URL', required=True)
48     parser_bootstrap.add_argument('--ipfileserver', help='Visible IP of the file server (SFTP/FTPS) to be included in the VES event',
49                                   type=validate_ip, metavar='IP', required=True)
50     parser_bootstrap.add_argument('--typefileserver', help='Type of the file server (SFTP/FTPS) to be included in the VES event',
51                                   type=str, choices=['sftp', 'ftps'], required=True)
52     parser_bootstrap.add_argument('--ipstart', help='IP address range beginning', type=validate_ip, metavar='IP', required=True)
53     # Start command parser
54     parser_start = subparsers.add_parser('start', help='Start instances')
55     parser_start.add_argument('--count', help='Instance count to start', type=int, metavar='INT', default=0)
56     # Stop command parser
57     parser_stop = subparsers.add_parser('stop', help='Stop instances')
58     parser_stop.add_argument('--count', help='Instance count to stop', type=int, metavar='INT', default=0)
59     # Trigger command parser
60     parser_trigger = subparsers.add_parser('trigger', help='Trigger one single VES event from each simulator')
61     parser_trigger.add_argument('--count', help='Instance count to trigger', type=int, metavar='INT', default=0)
62     # Stop-simulator command parser
63     parser_stopsimulator = subparsers.add_parser('stop_simulator', help='Stop sending PNF registration messages')
64     parser_stopsimulator.add_argument('--count', help='Instance count to stop', type=int, metavar='INT', default=0)
65     # Trigger-custom command parser
66     parser_triggerstart = subparsers.add_parser('trigger_custom', help='Trigger one single VES event from specific simulators')
67     parser_triggerstart.add_argument('--triggerstart', help='First simulator id to trigger', type=int,
68                                      metavar='INT', required=True)
69     parser_triggerstart.add_argument('--triggerend', help='Last simulator id to trigger', type=int,
70                                      metavar='INT', required=True)
71     # Status command parser
72     parser_status = subparsers.add_parser('status', help='Status')
73     parser_status.add_argument('--count', help='Instance count to show status for', type=int, metavar='INT', default=0)
74     # Clean command parser
75     subparsers.add_parser('clean', help='Clean work-dirs')
76     # General options parser
77     parser.add_argument('--verbose', help='Verbosity level', choices=['info', 'debug'],
78                         type=str, default='info')
79     return parser
80
81 class MassPnfSim:
82
83     # MassPnfSim class actions decorator
84     class _MassPnfSim_Decorators:
85
86         @staticmethod
87         def do_action(action_string, cmd):
88             def action_decorator(method):
89                 def action_wrap(self):
90                     # Alter looping range if action is 'tigger_custom'
91                     if method.__name__ == 'trigger_custom':
92                         iter_range = [self.args.triggerstart, self.args.triggerend+1]
93                     else:
94                         if not self.args.count:
95                             # If no instance count set explicitly via --count
96                             # option
97                             iter_range = [self.existing_sim_instances]
98                         else:
99                             iter_range = [self.args.count]
100                     method(self)
101                     for i in range(*iter_range):
102                         self.logger.info(f'{action_string} {self.sim_dirname_pattern}{i} instance:')
103                         self._run_cmd(cmd, f"{self.sim_dirname_pattern}{i}")
104                 return action_wrap
105             return action_decorator
106
107     log_lvl = logging.INFO
108     sim_config = 'config/config.yml'
109     sim_msg_config = 'config/config.json'
110     sim_port = 5000
111     sim_base_url = 'http://{}:' + str(sim_port) + '/simulator'
112     sim_start_url = sim_base_url + '/start'
113     sim_status_url = sim_base_url + '/status'
114     sim_stop_url = sim_base_url + '/stop'
115     sim_container_name = 'pnf-simulator'
116     rop_script_name = 'ROP_file_creator.sh'
117
118     def __init__(self, args):
119         self.args = args
120         self.logger = logging.getLogger(__name__)
121         self.logger.setLevel(self.log_lvl)
122         self.sim_dirname_pattern = "pnf-sim-lw-"
123         self.mvn_build_cmd = 'mvn clean package docker:build -Dcheckstyle.skip'
124         self.docker_compose_status_cmd = 'docker-compose ps'
125         self.existing_sim_instances = self._enum_sim_instances()
126
127         # Validate 'trigger_custom' subcommand options
128         if self.args.subcommand == 'trigger_custom':
129             if (self.args.triggerend + 1) > self.existing_sim_instances:
130                 self.logger.error('--triggerend value greater than existing instance count.')
131                 exit(1)
132
133         # Validate --count option for subcommands that support it
134         if self.args.subcommand in ['start', 'stop', 'trigger', 'status', 'stop_simulator']:
135             if self.args.count > self.existing_sim_instances:
136                 self.logger.error('--count value greater that existing instance count')
137                 exit(1)
138             if not self.existing_sim_instances:
139                 self.logger.error('No bootstrapped instance found')
140                 exit(1)
141
142         # Validate 'bootstrap' subcommand
143         if (self.args.subcommand == 'bootstrap') and self.existing_sim_instances:
144             self.logger.error('Bootstrapped instances detected, not overwiriting, clean first')
145             exit(1)
146
147     def _run_cmd(self, cmd, dir_context='.'):
148         old_pwd = getcwd()
149         try:
150             chdir(dir_context)
151             self.logger.debug(f'_run_cmd: Current direcotry: {getcwd()}')
152             self.logger.debug(f'_run_cmd: Command string: {cmd}')
153             run(cmd, check=True, shell=True)
154             chdir(old_pwd)
155         except FileNotFoundError:
156             self.logger.error(f"Directory {dir_context} not found")
157         except CalledProcessError as e:
158             exit(e.returncode)
159
160     def _enum_sim_instances(self):
161         '''Helper method that returns bootstraped simulator instances count'''
162         return len(glob(f"{self.sim_dirname_pattern}[0-9]*"))
163
164     def _get_sim_instance_data(self, instance_id):
165         '''Helper method that returns specific instance data'''
166         oldpwd = getcwd()
167         chdir(f"{self.sim_dirname_pattern}{instance_id}")
168         with open(self.sim_config) as cfg:
169             yml = load(cfg, Loader=SafeLoader)
170         chdir(oldpwd)
171         return yml['ippnfsim']
172
173     def _get_docker_containers(self):
174         '''Returns a list containing 'name' attribute of running docker containers'''
175         dc = from_env()
176         containers = []
177         for container in dc.containers.list():
178             containers.append(container.attrs['Name'][1:])
179         return containers
180
181     def _get_iter_range(self):
182         '''Helper routine to get the iteration range
183         for the lifecycle commands'''
184         if hasattr(self.args, 'count'):
185             if not self.args.count:
186                 return [self.existing_sim_instances]
187             else:
188                 return [self.args.count]
189         elif hasattr(self.args, 'triggerstart'):
190             return [self.args.triggerstart, self.args.triggerend + 1]
191         else:
192             return [self.existing_sim_instances]
193
194     def bootstrap(self):
195         self.logger.info("Bootstrapping PNF instances")
196
197         start_port = 2000
198         ftps_pasv_port_start = 8000
199         ftps_pasv_port_num_of_ports = 10
200
201         ftps_pasv_port_end = ftps_pasv_port_start + ftps_pasv_port_num_of_ports
202
203         for i in range(self.args.count):
204             self.logger.info(f"PNF simulator instance: {i}")
205
206             # The IP ranges are in distance of 16 compared to each other.
207             # This is matching the /28 subnet mask used in the dockerfile inside.
208             instance_ip_offset = i * 16
209             ip_properties = [
210                       'subnet',
211                       'gw',
212                       'PnfSim',
213                       'ftps',
214                       'sftp'
215                     ]
216
217             ip_offset = 0
218             ip = {}
219             for prop in ip_properties:
220                 ip.update({prop: str(self.args.ipstart + ip_offset + instance_ip_offset)})
221                 ip_offset += 1
222
223             self.logger.debug(f'Instance #{i} properties:\n {dumps(ip, indent=4)}')
224
225             PortSftp = start_port + 1
226             PortFtps = start_port + 2
227             start_port += 2
228
229             self.logger.info(f'\tCreating {self.sim_dirname_pattern}{i}')
230             copytree('pnf-sim-lightweight', f'{self.sim_dirname_pattern}{i}')
231
232             composercmd = " ".join([
233                     "./simulator.sh compose",
234                     ip['gw'],
235                     ip['subnet'],
236                     str(i),
237                     self.args.urlves,
238                     ip['PnfSim'],
239                     str(self.args.ipfileserver),
240                     self.args.typefileserver,
241                     str(PortSftp),
242                     str(PortFtps),
243                     ip['ftps'],
244                     ip['sftp'],
245                     str(ftps_pasv_port_start),
246                     str(ftps_pasv_port_end)
247                 ])
248             self.logger.debug(f"Script cmdline: {composercmd}")
249             self.logger.info(f"\tCreating instance #{i} configuration ")
250             self._run_cmd(composercmd, f"{self.sim_dirname_pattern}{i}")
251
252             ftps_pasv_port_start += ftps_pasv_port_num_of_ports + 1
253             ftps_pasv_port_end += ftps_pasv_port_num_of_ports + 1
254
255             self.logger.info(f'Done setting up instance #{i}')
256
257     def build(self):
258         self.logger.info("Building simulator image")
259         if path.isfile('pnf-sim-lightweight/pom.xml'):
260             self._run_cmd(self.mvn_build_cmd, 'pnf-sim-lightweight')
261         else:
262             self.logger.error('POM file was not found, Maven cannot run')
263             exit(1)
264
265     def clean(self):
266         self.logger.info('Cleaning simulators workdirs')
267         for sim_id in range(self.existing_sim_instances):
268             rmtree(f"{self.sim_dirname_pattern}{sim_id}")
269
270     @_MassPnfSim_Decorators.do_action('Starting', './simulator.sh start')
271     def start(self):
272         pass
273
274     def status(self):
275         for i in range(*self._get_iter_range()):
276             self.logger.info(f'Getting {self.sim_dirname_pattern}{i} instance status:')
277             if f"{self.sim_container_name}-{i}" in self._get_docker_containers():
278                 try:
279                     sim_ip = self._get_sim_instance_data(i)
280                     self.logger.info(f' PNF-Sim IP: {sim_ip}')
281                     self._run_cmd(self.docker_compose_status_cmd, f"{self.sim_dirname_pattern}{i}")
282                     sim_response = get('{}'.format(self.sim_status_url).format(sim_ip))
283                     if sim_response.status_code == codes.ok:
284                         self.logger.info(sim_response.text)
285                     else:
286                         self.logger.error(f'Simulator request returned http code {sim_response.status_code}')
287                 except KeyError:
288                     self.logger.error(f'Unable to get sim instance IP from {self.sim_config}')
289             else:
290                 self.logger.info(' Simulator containers are down')
291
292     def stop(self):
293         for i in range(*self._get_iter_range()):
294             self.logger.info(f'Stopping {self.sim_dirname_pattern}{i} instance:')
295             self.logger.info(f' PNF-Sim IP: {self._get_sim_instance_data(i)}')
296             # attempt killing ROP script
297             rop_pid = []
298             for ps_line in iter(popen(f'ps --no-headers -C {self.rop_script_name} -o pid,cmd').readline, ''):
299                 # try getting ROP script pid
300                 try:
301                     ps_line_arr = ps_line.split()
302                     assert self.rop_script_name in ps_line_arr[2]
303                     assert ps_line_arr[3] == str(i)
304                     rop_pid = ps_line_arr[0]
305                 except AssertionError:
306                     pass
307                 else:
308                     # get rop script childs, kill ROP script and all childs
309                     childs = popen(f'pgrep -P {rop_pid}').read().split()
310                     for pid in [rop_pid] + childs:
311                         kill(int(pid), 15)
312                     self.logger.info(f' ROP_file_creator.sh {i} successfully killed')
313             if not rop_pid:
314                 # no process found
315                 self.logger.warning(f' ROP_file_creator.sh {i} already not running')
316             # try tearing down docker-compose application
317             if f"{self.sim_container_name}-{i}" in self._get_docker_containers():
318                 self._run_cmd('docker-compose down', self.sim_dirname_pattern + str(i))
319                 self._run_cmd('docker-compose rm', self.sim_dirname_pattern + str(i))
320             else:
321                 self.logger.warning(" Simulator containers are already down")
322
323     def trigger(self):
324         self.logger.info("Triggering VES sending:")
325         for i in range(*self._get_iter_range()):
326             sim_ip = self._get_sim_instance_data(i)
327             self.logger.info(f'Triggering {self.sim_dirname_pattern}{i} instance:')
328             self.logger.info(f' PNF-Sim IP: {sim_ip}')
329             # setup req headers
330             req_headers = {
331                     "Content-Type": "application/json",
332                     "X-ONAP-RequestID": "123",
333                     "X-InvocationID": "456"
334                 }
335             self.logger.debug(f' Request headers: {req_headers}')
336             try:
337                 # get payload for the request
338                 with open(f'{self.sim_dirname_pattern}{i}/{self.sim_msg_config}') as data:
339                     json_data = loads(data.read())
340                     self.logger.debug(f' JSON payload for the simulator:\n{json_data}')
341                     # make a http request to the simulator
342                     sim_response = post('{}'.format(self.sim_start_url).format(sim_ip), headers=req_headers, json=json_data)
343                     if sim_response.status_code == codes.ok:
344                         self.logger.info(' Simulator response: ' + sim_response.text)
345                     else:
346                         self.logger.warning(' Simulator response ' + sim_response.text)
347             except TypeError:
348                 self.logger.error(f' Could not load JSON data from {self.sim_dirname_pattern}{i}/{self.sim_msg_config}')
349
350     # Make the 'trigger_custom' an alias to the 'trigger' method
351     trigger_custom = trigger
352
353     def stop_simulator(self):
354         self.logger.info("Stopping sending PNF registration messages:")
355         for i in range(*self._get_iter_range()):
356             sim_ip = self._get_sim_instance_data(i)
357             self.logger.info(f'Stopping {self.sim_dirname_pattern}{i} instance:')
358             self.logger.info(f' PNF-Sim IP: {sim_ip}')
359             sim_response = post('{}'.format(self.sim_stop_url).format(sim_ip))
360             if sim_response.status_code == codes.ok:
361                 self.logger.info(' Simulator response: ' + sim_response.text)
362             else:
363                 self.logger.warning(' Simulator response ' + sim_response.text)