blob: 5efc22da3ba01a16f87f2503f138a971db5fc12c [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 Hofmeyr6562c082017-10-18 03:20:04 +020022Common code for osmo_interact_vty.py and osmo_interact_ctrl.py.
23This implements all of application interaction, piping and verification.
24osmo_interact_{vty,ctrl}.py plug VTY and CTRL interface specific bits.
Neels Hofmeyr726b58d2017-10-15 03:01:09 +020025'''
26
27import argparse
28import sys
29import os
30import subprocess
31import time
32import traceback
33import socket
34import shlex
Neels Hofmeyr48b951a2017-11-29 18:21:18 +010035import re
Neels Hofmeyr726b58d2017-10-15 03:01:09 +020036
37
38class Interact:
39
40 class StepBase:
41 command = None
42 result = None
43 leading_blanks = None
44
45 def __init__(self):
46 self.result = []
47
48 def verify_interact_state(self, interact_instance):
49 # for example to verify that the last VTY prompt received shows the
50 # right node.
51 pass
52
53 def command_str(self, interact_instance=None):
54 return self.command
55
56 def __str__(self):
57 return '%s\n%s' % (self.command_str(), '\n'.join(self.result))
58
59 @staticmethod
60 def is_next_step(line, interact_instance):
61 assert not "implemented by InteractVty.VtyStep and InteractCtrl.CtrlStep"
62
63 socket = None
64
65 def __init__(self, step_class, port, host, verbose=False, update=False):
66 '''
67 host is the hostname to connect to.
68 port is the CTRL port to connect on.
69 '''
70 self.Step = step_class
71 self.port = port
72 self.host = host
73 self.verbose = verbose
74 self.update = update
75
Neels Hofmeyr7b5203f2017-10-18 02:09:08 +020076 if not port:
77 raise Exception("You need to provide port number to connect to")
78
Neels Hofmeyr726b58d2017-10-15 03:01:09 +020079 def connect(self):
80 assert self.socket is None
81 retries = 30
82 took = 0
83 while True:
84 took += 1
85 try:
86 self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
87 self.socket.setblocking(1)
88 self.socket.connect((self.host, int(self.port)))
89 except IOError:
90 retries -= 1
91 if retries <= 0:
92 raise
93 time.sleep(.1)
94 continue
95 break
96
97 def close(self):
98 if self.socket is None:
99 return
100 self.socket.close()
101 self.socket = None
102
103 def command(self, command):
104 assert not "implemented separately by InteractVty and InteractCtrl"
105
106 def verify_transcript_file(self, transcript_file):
107 with open(transcript_file, 'r') as f:
108 content = f.read()
109
110 try:
111 result = self.verify_transcript(content)
112 except:
113 print('Error while verifying transcript file %r' % transcript_file, file=sys.stderr)
114 sys.stderr.flush()
115 raise
116
117 if not self.update:
118 return
119 content = '\n'.join(result)
120 with open(transcript_file, 'w') as f:
121 f.write(content)
122
123 def verify_transcript(self, transcript):
124 ''''
125 transcript is a "screenshot" of a session, a multi-line string
126 including commands and expected results.
127 Feed commands to self.command() and verify the expected results.
128 '''
129
130 # parse steps
131 steps = []
132 step = None
133 blank_lines = 0
134 for line in transcript.splitlines():
135 if not line:
136 blank_lines += 1
137 continue
138 next_step_started = self.Step.is_next_step(line, self)
139 if next_step_started:
140 if step:
141 steps.append(step)
142 step = next_step_started
143 step.leading_blanks = blank_lines
144 blank_lines = 0
145 elif step:
146 # we only count blank lines directly preceding the start of a
147 # next step. Insert blank lines in the middle of a response
148 # back into the response:
149 if blank_lines:
150 step.result.extend([''] * blank_lines)
151 blank_lines = 0
152 step.result.append(line)
153 if step:
154 steps.append(step)
155 step = None
156
157 actual_result = []
158
159 # run steps
160 step_nr = 0
161 for step in steps:
162 step_nr += 1
163 try:
164 if self.verbose:
165 if step.leading_blanks:
166 print('\n' * step.leading_blanks, end='')
167 print(step.command_str())
168 sys.stdout.flush()
169
170 step.verify_interact_state(self)
171
172 res = self.command(step.command)
173
174 if self.verbose:
175 sys.stderr.flush()
176 sys.stdout.flush()
177 print('\n'.join(res))
178 sys.stdout.flush()
179
180 if step.leading_blanks:
181 actual_result.extend([''] * step.leading_blanks)
182 actual_result.append(step.command_str(self))
183
184 match_result = self.match_lines(step.result, res)
185
186 if self.update:
187 if match_result is True:
188 # preserve any wildcards
189 actual_result.extend(step.result)
190 else:
191 # mismatch, take exactly what came in
192 actual_result.extend(res)
193 continue
194 if match_result is not True:
195 raise Exception('Result mismatch:\n%s\n\nExpected:\n[\n%s\n]\n\nGot:\n[\n%s\n%s\n]'
196 % (match_result, step, step.command_str(), '\n'.join(res)))
197 except:
198 print('Error during transcript step %d:\n[\n%s\n]' % (step_nr, step),
199 file=sys.stderr)
200 sys.stderr.flush()
201 raise
202
203 # final line ending
204 actual_result.append('')
205 return actual_result
206
207 @staticmethod
208 def match_lines(expect, got):
209 '''
210 Match two lists of strings, allowing certain wildcards:
211 - In 'expect', if a line is exactly '...', it matches any number of
212 arbitrary lines in 'got'; the implementation is trivial and skips
213 lines to the first occurence in 'got' that continues after '...'.
Neels Hofmeyr48b951a2017-11-29 18:21:18 +0100214 - If an 'expect' line is '... !regex', it matches any number of
215 lines like '...', but the given regex must not match any of those
216 lines.
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200217
218 Return 'True' on match, or a string describing the mismatch.
219 '''
220 def match_line(expect_line, got_line):
221 return expect_line == got_line
222
Neels Hofmeyr48b951a2017-11-29 18:21:18 +0100223 ANY = '...'
224 ANY_EXCEPT = '... !'
225
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200226 e = 0
227 g = 0
228 while e < len(expect):
Neels Hofmeyr48b951a2017-11-29 18:21:18 +0100229 if expect[e] == ANY or expect[e].startswith(ANY_EXCEPT):
230 wildcard = expect[e]
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200231 e += 1
Neels Hofmeyr48b951a2017-11-29 18:21:18 +0100232 g_end = g
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200233
234 if e >= len(expect):
235 # anything left in 'got' is accepted.
Neels Hofmeyr48b951a2017-11-29 18:21:18 +0100236 g_end = len(got)
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200237
238 # look for the next occurence of the expected line in 'got'
Neels Hofmeyr48b951a2017-11-29 18:21:18 +0100239 while g_end < len(got) and not match_line(expect[e], got[g_end]):
240 g_end += 1
241
242 if wildcard == ANY:
243 # no restrictions on lines
244 g = g_end
245
246 elif wildcard.startswith(ANY_EXCEPT):
247 except_re = re.compile(wildcard[len(ANY_EXCEPT):])
248 while g < g_end:
249 if except_re.search(got[g]):
250 return ('Got forbidden line for wildcard %r:'
251 ' did not expect %r in line %d of response'
252 % (wildcard, got[g], g))
253 g += 1
254
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200255 continue
256
257 if g >= len(got):
258 return 'Cannot find line %r' % expect[e]
259
260 if not match_line(expect[e], got[g]):
261 return 'Mismatch:\nExpect:\n%r\nGot:\n%r' % (expect[e], got[g])
262
263 e += 1
264 g += 1
265
266 if g < len(got):
267 return 'Did not expect line %r' % got[g]
268 return True
269
Neels Hofmeyr08d645b2017-10-18 02:45:10 +0200270 def feed_commands(self, output, command_strs):
271 for command_str in command_strs:
272 for command in command_str.splitlines():
273 res = self.command(command)
274 output.write('\n'.join(res))
275 output.write('\n')
276
Neels Hofmeyr6562c082017-10-18 03:20:04 +0200277def end_process(proc, quiet=False):
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200278 if not proc:
279 return
280
281 rc = proc.poll()
282 if rc is not None:
Neels Hofmeyr6562c082017-10-18 03:20:04 +0200283 if not quiet:
284 print('Process has already terminated with', rc)
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200285 proc.wait()
286 return
287
288 proc.terminate()
289 time_to_wait_for_term = 5
290 wait_step = 0.001
291 waited_time = 0
292 while True:
293 # poll returns None if proc is still running
294 if proc.poll() is not None:
295 break
296 waited_time += wait_step
297 # make wait_step approach 1.0
298 wait_step = (1. + 5. * wait_step) / 6.
299 if waited_time >= time_to_wait_for_term:
300 break
301 time.sleep(wait_step)
302
303 if proc.poll() is None:
304 # termination seems to be slower than that, let's just kill
305 proc.kill()
Neels Hofmeyr6562c082017-10-18 03:20:04 +0200306 if not quiet:
307 print("Killed child process")
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200308 elif waited_time > .002:
Neels Hofmeyr6562c082017-10-18 03:20:04 +0200309 if not quiet:
310 print("Terminating took %.3fs" % waited_time)
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200311 proc.wait()
312
313class Application:
314 proc = None
315 _devnull = None
316
317 @staticmethod
318 def devnull():
319 if Application._devnull is None:
320 Application._devnull = open(os.devnull, 'w')
321 return Application._devnull
322
Neels Hofmeyr08d645b2017-10-18 02:45:10 +0200323 def __init__(self, run_app_str, purge_output=True, quiet=False):
324 self.command_tuple = shlex.split(run_app_str)
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200325 self.purge_output = purge_output
Neels Hofmeyr08d645b2017-10-18 02:45:10 +0200326 self.quiet = quiet
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200327
328 def run(self):
329 out_err = None
330 if self.purge_output:
331 out_err = Application.devnull()
332
Neels Hofmeyr08d645b2017-10-18 02:45:10 +0200333 if not self.quiet:
334 print('Launching: cd %r; %s' % (os.getcwd(), ' '.join(self.command_tuple)))
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200335 self.proc = subprocess.Popen(self.command_tuple, stdout=out_err, stderr=out_err)
336
337 def stop(self):
Neels Hofmeyr6562c082017-10-18 03:20:04 +0200338 end_process(self.proc, self.quiet)
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200339
Neels Hofmeyr08d645b2017-10-18 02:45:10 +0200340def verify_application(run_app_str, interact, transcript_file, verbose):
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200341 passed = None
342 application = None
343
344 sys.stdout.flush()
345 sys.stderr.flush()
346
Neels Hofmeyr08d645b2017-10-18 02:45:10 +0200347 if run_app_str:
348 application = Application(run_app_str, purge_output=not verbose)
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200349 application.run()
350
351 try:
352 interact.connect()
353 interact.verify_transcript_file(transcript_file)
354 passed = True
355 except:
356 traceback.print_exc()
357 passed = False
358 interact.close()
359
360 if application:
361 application.stop()
362
363 sys.stdout.flush()
364 sys.stderr.flush()
365
366 return passed
367
368def common_parser():
369 parser = argparse.ArgumentParser()
Neels Hofmeyr08d645b2017-10-18 02:45:10 +0200370 parser.add_argument('-r', '--run', dest='run_app_str',
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200371 help='command to run to launch application to test,'
372 ' including command line arguments. If omitted, no'
373 ' application is launched.')
374 parser.add_argument('-p', '--port', dest='port',
Neels Hofmeyr066a95d2017-10-18 03:53:06 +0200375 help="Port to reach the application at.")
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200376 parser.add_argument('-H', '--host', dest='host', default='localhost',
Neels Hofmeyr066a95d2017-10-18 03:53:06 +0200377 help="Host to reach the application at.")
Neels Hofmeyr6562c082017-10-18 03:20:04 +0200378 return parser
379
380def parser_add_verify_args(parser):
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200381 parser.add_argument('-u', '--update', dest='update', action='store_true',
382 help='Do not verify, but OVERWRITE transcripts based on'
Neels Hofmeyr066a95d2017-10-18 03:53:06 +0200383 ' the application\'s current behavior. OVERWRITES TRANSCRIPT'
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200384 ' FILES.')
385 parser.add_argument('-v', '--verbose', action='store_true',
386 help='Print commands and application output')
Neels Hofmeyr6562c082017-10-18 03:20:04 +0200387 parser.add_argument('transcript_files', nargs='*', help='transcript file(s) to verify')
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200388 return parser
389
Neels Hofmeyr6562c082017-10-18 03:20:04 +0200390def parser_add_run_args(parser):
391 parser.add_argument('-O', '--output', dest='output_path',
392 help="Write command results to a file instead of stdout."
393 "('-O -' writes to stdout and is the default)")
394 parser.add_argument('-c', '--command', dest='cmd_str',
395 help="Run this command (before reading input files, if any)."
396 " multiple commands may be separated by ';'")
397 parser.add_argument('cmd_files', nargs='*', help='file(s) with plain commands to run')
398 return parser
399
400def main_run_commands(run_app_str, output_path, cmd_str, cmd_files, interact):
Neels Hofmeyr08d645b2017-10-18 02:45:10 +0200401 to_stdout = False
Neels Hofmeyr6562c082017-10-18 03:20:04 +0200402 if not output_path or output_path == '-':
Neels Hofmeyr08d645b2017-10-18 02:45:10 +0200403 to_stdout = True
404 output = sys.stdout
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200405 else:
Neels Hofmeyr08d645b2017-10-18 02:45:10 +0200406 output = open(output_path, 'w')
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200407
Neels Hofmeyr08d645b2017-10-18 02:45:10 +0200408 application = None
409
410 if run_app_str:
411 application = Application(run_app_str, quiet=to_stdout)
412 application.run()
413
414 try:
415 interact.connect()
416
417 if cmd_str:
418 interact.feed_commands(output, cmd_str.split(';'))
419
420 for f_path in (cmd_files or []):
421 with open(f_path, 'r') as f:
422 interact.feed_commands(output, f.read().decode('utf-8').splitlines())
423
424 if not (cmd_str or cmd_files):
425 while True:
426 line = sys.stdin.readline()
427 if not line:
428 break;
429 interact.feed_commands(output, line.split(';'))
430 except:
431 traceback.print_exc()
432 finally:
433 if not to_stdout:
434 try:
435 output.close()
436 except:
437 traceback.print_exc()
438
439 try:
440 interact.close()
441 except:
442 traceback.print_exc()
443
444 if application:
445 try:
446 application.stop()
447 except:
448 traceback.print_exc()
449
Neels Hofmeyr6562c082017-10-18 03:20:04 +0200450def main_verify_transcripts(run_app_str, transcript_files, interact, verbose):
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200451 results = []
452 for t in transcript_files:
Neels Hofmeyr08d645b2017-10-18 02:45:10 +0200453 passed = verify_application(run_app_str=run_app_str,
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200454 interact=interact,
455 transcript_file=t,
456 verbose=verbose)
457 results.append((passed, t))
458
459 print('\nRESULTS:')
460 all_passed = True
461 for passed, t in results:
462 print('%s: %s' % ('pass' if passed else 'FAIL', t))
463 all_passed = all_passed and passed
464 print()
465
466 if not all_passed:
467 sys.exit(1)
468
Neels Hofmeyr726b58d2017-10-15 03:01:09 +0200469# vim: tabstop=4 shiftwidth=4 expandtab nocin ai