4 from subprocess import run, CalledProcessError
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
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
18 def validate_url(url):
19 '''Helper function to perform --urlves input param validation'''
20 logger = logging.getLogger("urllib3")
21 logger.setLevel(logging.WARNING)
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):
30 def merge_dictionaries(origin, custom):
31 '''Combine 2 dictionaries based on common keys.'''
35 **custom.get(key, {}))
36 for key in origin.keys() | custom.keys()
40 '''Helper function to validate input param is a vaild IP address'''
42 ip_valid = ipaddress.ip_address(ip)
44 raise argparse.ArgumentTypeError(f'{ip} is not a valid IP address')
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
57 '''Process input arguments'''
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)
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')
109 # MassPnfSim class actions decorator
110 class _MassPnfSim_Decorators:
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.')
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')
125 if not self._enum_sim_instances():
126 self.logger.error('No bootstrapped instance found')
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')
137 def substitute_instance_args(method):
138 def wrapper(self, args):
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'
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'
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'
166 def _run_cmd(self, cmd, 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)
174 except FileNotFoundError:
175 self.logger.error(f"Directory {dir_context} not found")
176 except CalledProcessError as e:
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]*"))
183 def _get_sim_instance_data(self, instance_id):
184 '''Helper method that returns specific instance data'''
186 chdir(f"{self.sim_dirname_pattern}{instance_id}")
187 with open(self.sim_config) as cfg:
188 yml = load(cfg, Loader=SafeLoader)
190 return yml['ippnfsim']
192 def _get_docker_containers(self):
193 '''Returns a list containing 'name' attribute of running docker containers'''
196 for container in dc.containers.list():
197 containers.append(container.attrs['Name'][1:])
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()]
207 return [self.args.count]
208 elif hasattr(self.args, 'triggerstart'):
209 return [self.args.triggerstart, self.args.triggerend + 1]
211 return [self._enum_sim_instances()]
213 def _archive_logs(self, sim_dir):
214 '''Helper function to archive simulator logs or create the log dir'''
218 if path.isdir('logs'):
219 arch_dir = f"logs/archive_{strftime('%Y-%m-%d_%T')}"
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
228 self.logger.debug(f'Moving {f} to {arch_dir}')
231 self.logger.debug("Logs dir didn't exist, created")
233 except FileNotFoundError:
234 self.logger.error(f"Directory {sim_dir} not found")
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'''
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))
248 def _generate_config_file(self, source, dest, **kwargs):
249 '''Helper private method to generate a file based on a template'''
251 chdir(self.sim_dirname_pattern + str(kwargs['I']))
252 # Read the template file
253 with open(source, 'r') as f:
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:
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")
269 ftps_pasv_port_start = 8000
270 ftps_pasv_port_num_of_ports = 10
272 ftps_pasv_port_end = ftps_pasv_port_start + ftps_pasv_port_num_of_ports
274 for i in range(self.args.count):
275 self.logger.info(f"PNF simulator instance: {i}")
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
290 for prop in ip_properties:
291 ip.update({prop: str(self.args.ipstart + ip_offset + instance_ip_offset)})
294 self.logger.debug(f'Instance #{i} properties:\n {dumps(ip, indent=4)}')
296 PortSftp = start_port + 1
297 PortFtps = start_port + 2
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,
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)
327 ftps_pasv_port_start += ftps_pasv_port_num_of_ports + 1
328 ftps_pasv_port_end += ftps_pasv_port_num_of_ports + 1
330 # ugly hack to chown vsftpd config file to root
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}")
335 self.logger.info(f'Done setting up instance #{i}')
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')
343 self.logger.error('POM file was not found, Maven cannot run')
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}")
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
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
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:
367 self.logger.warning(f'3GPP measurements file generator for instance {i} is already 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))
381 self.logger.warning(f'Instance {self.sim_dirname_pattern}{i} containers are already up')
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():
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)
397 self.logger.error(f'Simulator request returned http code {sim_response.status_code}')
399 self.logger.error(f'Unable to get sim instance IP from {self.sim_config}')
401 self.logger.info(' Simulator containers are down')
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
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
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:
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:
425 self.logger.info(f' ROP_file_creator.sh {i} successfully killed')
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))
434 self.logger.warning(" Simulator containers are already down")
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:")
441 for i in range(*self._get_iter_range()):
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}')
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))
455 "Content-Type": "application/json",
456 "X-ONAP-RequestID": "123",
457 "X-InvocationID": "456",
458 "Authorization": basic_auth_token
460 self.logger.debug(f' Request headers: {req_headers}')
464 # get payload for the request
465 with open(f'{self.sim_dirname_pattern}{i}/{self.sim_msg_config}') as data:
467 json_data = loads(data.read())
469 json_data = merge_dictionaries(json_data, args.data)
470 except AttributeError:
471 self.logger.debug('The request will be sent without customization.')
473 self.logger.debug(f' JSON payload for the simulator:\n{json_data}')
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)
478 if sim_response.status_code == codes.ok:
479 self.logger.info(' Simulator response: ' + sim_response.text)
481 self.logger.warning(' Simulator response ' + sim_response.text)
484 self.logger.error(f' Could not load JSON data from {self.sim_dirname_pattern}{i}/{self.sim_msg_config}')
486 # Make the 'trigger_custom' an alias to the 'trigger' method
487 trigger_custom = trigger
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)
501 self.logger.warning(' Simulator response ' + sim_response.text)