blob: 39163a246bbaf4021c3a2c981b0b1e028e26c9ca [file] [log] [blame]
Neels Hofmeyr726b58d2017-10-15 03:01:09 +02001#!/usr/bin/env python3
2#
3# (C) 2017 by sysmocom s.f.m.c. GmbH <info@sysmocom.de>
4# All rights reserved.
5#
6# Author: Neels Hofmeyr <nhofmeyr@sysmocom.de>
7#
8# This program is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License as published by
10# the Free Software Foundation, either version 3 of the License, or
11# (at your option) any later version.
12#
13# This program is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16# GNU General Public License for more details.
17#
18# You should have received a copy of the GNU General Public License
19# along with this program. If not, see <http://www.gnu.org/licenses/>.
20
21'''
Neels Hofmeyrbe76f4d2017-12-19 13:46:57 +010022Common code for VTY and CTRL interface interaction and transcript verification.
Neels Hofmeyr6562c082017-10-18 03:20:04 +020023This implements all of application interaction, piping and verification.
Neels Hofmeyrbe76f4d2017-12-19 13:46:57 +010024vty.py and ctrl.py plug VTY and CTRL interface specific bits.
Neels Hofmeyr726b58d2017-10-15 03:01:09 +020025'''
26
Neels Hofmeyr56aa4782017-12-19 14:12:16 +010027# Our setup.py currently wants everything to be parsable by both py2 and py3.
28# IMHO that is not a good idea, but until that changes, let's just keep this
29# py2 legacy shim in here so we can syntax-check this py3 module with py2.
30from __future__ import print_function
31
Neels Hofmeyr726b58d2017-10-15 03:01:09 +020032import argparse
33import sys
34import os
35import subprocess
36import time
37import traceback
38import socket
39import shlex
Neels Hofmeyr48b951a2017-11-29 18:21:18 +010040import re
Neels Hofmeyr726b58d2017-10-15 03:01:09 +020041
42
43class Interact:
44
45 class StepBase:
46 command = None
47 result = None
48 leading_blanks = None
49
50 def __init__(self):
51 self.result = []
52
53 def verify_interact_state(self, interact_instance):
54 # for example to verify that the last VTY prompt received shows the
55 # right node.
56 pass
57
58 def command_str(self, interact_instance=None):
59 return self.command
60
61 def __str__(self):
62 return '%s\n%s' % (self.command_str(), '\n'.join(self.result))
63
64 @staticmethod
65 def is_next_step(line, interact_instance):
66 assert not "implemented by InteractVty.VtyStep and InteractCtrl.CtrlStep"
67
68 socket = None
69
70 def __init__(self, step_class, port, host, verbose=False, update=False):
71 '''
72 host is the hostname to connect to.
73 port is the CTRL port to connect on.
74 '''
75 self.Step = step_class
76 self.port = port
77 self.host = host
78 self.verbose = verbose
79 self.update = update
80
Neels Hofmeyr7b5203f2017-10-18 02:09:08 +020081 if not port:
82 raise Exception("You need to provide port number to connect to")
83
Neels Hofmeyr726b58d2017-10-15 03:01:09 +020084 def connect(self):
85 assert self.socket is None
86 retries = 30
87 took = 0
88 while True:
89 took += 1
90 try:
91 self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
92 self.socket.setblocking(1)
93 self.socket.connect((self.host, int(self.port)))
94 except IOError:
95 retries -= 1
96 if retries <= 0:
97 raise
98 time.sleep(.1)
99 continue
100 break
101
102 def close(self):
103 if self.socket is None:
104 return
105 self.socket.close()
106 self.socket = None
107
108 def command(self, command):
109 assert not "implemented separately by InteractVty and InteractCtrl"
110
111 def verify_transcript_file(self, transcript_file):
112 with open(transcript_file, 'r') as f:
113 content = f.read()
114
115 try:
116 result = self.verify_transcript(content)
117 except:
118 print('Error while verifying transcript file %r' % transcript_file, file=sys.stderr)
119 sys.stderr.flush()
120 raise
121
122 if not self.update:
123 return
124 content = '\n'.join(result)
125 with open(transcript_file, 'w') as f:
126 f.write(content)
127
128 def verify_transcript(self, transcript):
129 ''''
130 transcript is a "screenshot" of a session, a multi-line string
131 including commands and expected results.
132 Feed commands to self.command() and verify the expected results.
133 '''
134
135 # parse steps
136 steps = []
137 step = None
138 blank_lines = 0
139 for line in transcript.splitlines():
140 if not line:
141 blank_lines += 1
142 continue
143 next_step_started = self.Step.is_next_step(line, self)
144 if next_step_started:
145 if step:
146 steps.append(step)
147 step = next_step_started
148 step.leading_blanks = blank_lines
149 blank_lines = 0
150 elif step:
151 # we only count blank lines directly preceding the start of a
152 # next step. Insert blank lines in the middle of a response
153 # back into the response:
154 if blank_lines:
155 step.result.extend([''] * blank_lines)
156 blank_lines = 0
157 step.result.append(line)
158 if step:
159 steps.append(step)
160 step = None
161
162 actual_result = []
163
164 # run steps
165 step_nr = 0
166 for step in steps:
167 step_nr += 1
168 try:
169 if self.verbose:
170 if step.leading_blanks:
171 print('\n' * step.leading_blanks, end='')
172 print(step.command_str())
173 sys.stdout.flush()
174
175 step.verify_interact_state(self)
176
177 res = self.command(step.command)
Neels Hofmeyre0325b42018-09-10 15:49:54 +0200178 # trailing empty lines in the command output cannot be preserved because we allow
179 # arbitrary newlines between commands. Do not even track these.
180 while res and not res[-1]:
181 res = res[:-1]
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200182
183 if self.verbose:
184 sys.stderr.flush()
185 sys.stdout.flush()
186 print('\n'.join(res))
187 sys.stdout.flush()
188
189 if step.leading_blanks:
190 actual_result.extend([''] * step.leading_blanks)
191 actual_result.append(step.command_str(self))
192
193 match_result = self.match_lines(step.result, res)
194
195 if self.update:
196 if match_result is True:
197 # preserve any wildcards
198 actual_result.extend(step.result)
199 else:
200 # mismatch, take exactly what came in
201 actual_result.extend(res)
202 continue
203 if match_result is not True:
204 raise Exception('Result mismatch:\n%s\n\nExpected:\n[\n%s\n]\n\nGot:\n[\n%s\n%s\n]'
205 % (match_result, step, step.command_str(), '\n'.join(res)))
206 except:
207 print('Error during transcript step %d:\n[\n%s\n]' % (step_nr, step),
208 file=sys.stderr)
209 sys.stderr.flush()
210 raise
211
212 # final line ending
213 actual_result.append('')
214 return actual_result
215
216 @staticmethod
217 def match_lines(expect, got):
218 '''
219 Match two lists of strings, allowing certain wildcards:
220 - In 'expect', if a line is exactly '...', it matches any number of
221 arbitrary lines in 'got'; the implementation is trivial and skips
222 lines to the first occurence in 'got' that continues after '...'.
Neels Hofmeyr48b951a2017-11-29 18:21:18 +0100223 - If an 'expect' line is '... !regex', it matches any number of
224 lines like '...', but the given regex must not match any of those
225 lines.
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200226
227 Return 'True' on match, or a string describing the mismatch.
228 '''
229 def match_line(expect_line, got_line):
230 return expect_line == got_line
231
Neels Hofmeyr48b951a2017-11-29 18:21:18 +0100232 ANY = '...'
233 ANY_EXCEPT = '... !'
234
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200235 e = 0
236 g = 0
237 while e < len(expect):
Neels Hofmeyr48b951a2017-11-29 18:21:18 +0100238 if expect[e] == ANY or expect[e].startswith(ANY_EXCEPT):
239 wildcard = expect[e]
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200240 e += 1
Neels Hofmeyr48b951a2017-11-29 18:21:18 +0100241 g_end = g
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200242
243 if e >= len(expect):
244 # anything left in 'got' is accepted.
Neels Hofmeyr48b951a2017-11-29 18:21:18 +0100245 g_end = len(got)
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200246
247 # look for the next occurence of the expected line in 'got'
Neels Hofmeyr48b951a2017-11-29 18:21:18 +0100248 while g_end < len(got) and not match_line(expect[e], got[g_end]):
249 g_end += 1
250
251 if wildcard == ANY:
252 # no restrictions on lines
253 g = g_end
254
255 elif wildcard.startswith(ANY_EXCEPT):
256 except_re = re.compile(wildcard[len(ANY_EXCEPT):])
257 while g < g_end:
258 if except_re.search(got[g]):
259 return ('Got forbidden line for wildcard %r:'
260 ' did not expect %r in line %d of response'
261 % (wildcard, got[g], g))
262 g += 1
263
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200264 continue
265
266 if g >= len(got):
267 return 'Cannot find line %r' % expect[e]
268
269 if not match_line(expect[e], got[g]):
270 return 'Mismatch:\nExpect:\n%r\nGot:\n%r' % (expect[e], got[g])
271
272 e += 1
273 g += 1
274
275 if g < len(got):
276 return 'Did not expect line %r' % got[g]
277 return True
278
Neels Hofmeyr08d645b2017-10-18 02:45:10 +0200279 def feed_commands(self, output, command_strs):
280 for command_str in command_strs:
281 for command in command_str.splitlines():
282 res = self.command(command)
283 output.write('\n'.join(res))
284 output.write('\n')
285
Neels Hofmeyr6562c082017-10-18 03:20:04 +0200286def end_process(proc, quiet=False):
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200287 if not proc:
288 return
289
290 rc = proc.poll()
291 if rc is not None:
Neels Hofmeyr6562c082017-10-18 03:20:04 +0200292 if not quiet:
293 print('Process has already terminated with', rc)
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200294 proc.wait()
295 return
296
297 proc.terminate()
298 time_to_wait_for_term = 5
299 wait_step = 0.001
300 waited_time = 0
301 while True:
302 # poll returns None if proc is still running
303 if proc.poll() is not None:
304 break
305 waited_time += wait_step
306 # make wait_step approach 1.0
307 wait_step = (1. + 5. * wait_step) / 6.
308 if waited_time >= time_to_wait_for_term:
309 break
310 time.sleep(wait_step)
311
312 if proc.poll() is None:
313 # termination seems to be slower than that, let's just kill
314 proc.kill()
Neels Hofmeyr6562c082017-10-18 03:20:04 +0200315 if not quiet:
316 print("Killed child process")
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200317 elif waited_time > .002:
Neels Hofmeyr6562c082017-10-18 03:20:04 +0200318 if not quiet:
319 print("Terminating took %.3fs" % waited_time)
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200320 proc.wait()
321
322class Application:
323 proc = None
324 _devnull = None
325
326 @staticmethod
327 def devnull():
328 if Application._devnull is None:
329 Application._devnull = open(os.devnull, 'w')
330 return Application._devnull
331
Neels Hofmeyr08d645b2017-10-18 02:45:10 +0200332 def __init__(self, run_app_str, purge_output=True, quiet=False):
333 self.command_tuple = shlex.split(run_app_str)
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200334 self.purge_output = purge_output
Neels Hofmeyr08d645b2017-10-18 02:45:10 +0200335 self.quiet = quiet
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200336
337 def run(self):
338 out_err = None
339 if self.purge_output:
340 out_err = Application.devnull()
341
Neels Hofmeyr08d645b2017-10-18 02:45:10 +0200342 if not self.quiet:
343 print('Launching: cd %r; %s' % (os.getcwd(), ' '.join(self.command_tuple)))
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200344 self.proc = subprocess.Popen(self.command_tuple, stdout=out_err, stderr=out_err)
345
346 def stop(self):
Neels Hofmeyr6562c082017-10-18 03:20:04 +0200347 end_process(self.proc, self.quiet)
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200348
Neels Hofmeyr08d645b2017-10-18 02:45:10 +0200349def verify_application(run_app_str, interact, transcript_file, verbose):
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200350 passed = None
351 application = None
352
353 sys.stdout.flush()
354 sys.stderr.flush()
355
Neels Hofmeyr08d645b2017-10-18 02:45:10 +0200356 if run_app_str:
357 application = Application(run_app_str, purge_output=not verbose)
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200358 application.run()
359
360 try:
361 interact.connect()
362 interact.verify_transcript_file(transcript_file)
363 passed = True
364 except:
365 traceback.print_exc()
366 passed = False
367 interact.close()
368
369 if application:
370 application.stop()
371
372 sys.stdout.flush()
373 sys.stderr.flush()
374
375 return passed
376
Neels Hofmeyrbe76f4d2017-12-19 13:46:57 +0100377def common_parser(doc=None):
378 parser = argparse.ArgumentParser(description=doc,
379 formatter_class=argparse.RawDescriptionHelpFormatter)
Neels Hofmeyr08d645b2017-10-18 02:45:10 +0200380 parser.add_argument('-r', '--run', dest='run_app_str',
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200381 help='command to run to launch application to test,'
382 ' including command line arguments. If omitted, no'
383 ' application is launched.')
384 parser.add_argument('-p', '--port', dest='port',
Neels Hofmeyr066a95d2017-10-18 03:53:06 +0200385 help="Port to reach the application at.")
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200386 parser.add_argument('-H', '--host', dest='host', default='localhost',
Neels Hofmeyr066a95d2017-10-18 03:53:06 +0200387 help="Host to reach the application at.")
Neels Hofmeyr6562c082017-10-18 03:20:04 +0200388 return parser
389
390def parser_add_verify_args(parser):
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200391 parser.add_argument('-u', '--update', dest='update', action='store_true',
392 help='Do not verify, but OVERWRITE transcripts based on'
Neels Hofmeyr066a95d2017-10-18 03:53:06 +0200393 ' the application\'s current behavior. OVERWRITES TRANSCRIPT'
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200394 ' FILES.')
395 parser.add_argument('-v', '--verbose', action='store_true',
396 help='Print commands and application output')
Neels Hofmeyr6562c082017-10-18 03:20:04 +0200397 parser.add_argument('transcript_files', nargs='*', help='transcript file(s) to verify')
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200398 return parser
399
Neels Hofmeyr6562c082017-10-18 03:20:04 +0200400def parser_add_run_args(parser):
401 parser.add_argument('-O', '--output', dest='output_path',
402 help="Write command results to a file instead of stdout."
403 "('-O -' writes to stdout and is the default)")
404 parser.add_argument('-c', '--command', dest='cmd_str',
405 help="Run this command (before reading input files, if any)."
406 " multiple commands may be separated by ';'")
407 parser.add_argument('cmd_files', nargs='*', help='file(s) with plain commands to run')
408 return parser
409
410def main_run_commands(run_app_str, output_path, cmd_str, cmd_files, interact):
Neels Hofmeyr08d645b2017-10-18 02:45:10 +0200411 to_stdout = False
Neels Hofmeyr6562c082017-10-18 03:20:04 +0200412 if not output_path or output_path == '-':
Neels Hofmeyr08d645b2017-10-18 02:45:10 +0200413 to_stdout = True
414 output = sys.stdout
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200415 else:
Neels Hofmeyr08d645b2017-10-18 02:45:10 +0200416 output = open(output_path, 'w')
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200417
Neels Hofmeyr08d645b2017-10-18 02:45:10 +0200418 application = None
419
420 if run_app_str:
421 application = Application(run_app_str, quiet=to_stdout)
422 application.run()
423
424 try:
425 interact.connect()
426
427 if cmd_str:
428 interact.feed_commands(output, cmd_str.split(';'))
429
430 for f_path in (cmd_files or []):
431 with open(f_path, 'r') as f:
432 interact.feed_commands(output, f.read().decode('utf-8').splitlines())
433
434 if not (cmd_str or cmd_files):
435 while True:
436 line = sys.stdin.readline()
437 if not line:
438 break;
439 interact.feed_commands(output, line.split(';'))
440 except:
441 traceback.print_exc()
442 finally:
443 if not to_stdout:
444 try:
445 output.close()
446 except:
447 traceback.print_exc()
448
449 try:
450 interact.close()
451 except:
452 traceback.print_exc()
453
454 if application:
455 try:
456 application.stop()
457 except:
458 traceback.print_exc()
459
Neels Hofmeyr6562c082017-10-18 03:20:04 +0200460def main_verify_transcripts(run_app_str, transcript_files, interact, verbose):
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200461 results = []
462 for t in transcript_files:
Neels Hofmeyr08d645b2017-10-18 02:45:10 +0200463 passed = verify_application(run_app_str=run_app_str,
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200464 interact=interact,
465 transcript_file=t,
466 verbose=verbose)
467 results.append((passed, t))
468
469 print('\nRESULTS:')
470 all_passed = True
471 for passed, t in results:
472 print('%s: %s' % ('pass' if passed else 'FAIL', t))
473 all_passed = all_passed and passed
474 print()
475
476 if not all_passed:
477 sys.exit(1)
478
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200479# vim: tabstop=4 shiftwidth=4 expandtab nocin ai