mirror of https://github.com/redis/redis.git
				
				
				
			
		
			
				
	
	
		
			350 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable File
		
	
	
			
		
		
	
	
			350 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable File
		
	
	
| #!/usr/bin/env python3
 | |
| import os
 | |
| import glob
 | |
| import json
 | |
| import sys
 | |
| 
 | |
| import jsonschema
 | |
| import subprocess
 | |
| import redis
 | |
| import time
 | |
| import argparse
 | |
| import multiprocessing
 | |
| import collections
 | |
| import io
 | |
| import signal
 | |
| import traceback
 | |
| from datetime import timedelta
 | |
| from functools import partial
 | |
| try:
 | |
|     from jsonschema import Draft201909Validator as schema_validator
 | |
| except ImportError:
 | |
|     from jsonschema import Draft7Validator as schema_validator
 | |
| 
 | |
| """
 | |
| The purpose of this file is to validate the reply_schema values of COMMAND DOCS.
 | |
| Basically, this is what it does:
 | |
| 1. Goes over req-res files, generated by redis-servers, spawned by the testsuite (see logreqres.c)
 | |
| 2. For each request-response pair, it validates the response against the request's reply_schema (obtained from COMMAND DOCS)
 | |
| 
 | |
| This script spins up a redis-server and a redis-cli in order to obtain COMMAND DOCS.
 | |
| 
 | |
| In order to use this file you must run the redis testsuite with the following flags:
 | |
| ./runtest --dont-clean --force-resp3 --log-req-res
 | |
| 
 | |
| And then:
 | |
| ./utils/req-res-log-validator.py
 | |
| 
 | |
| The script will fail only if:
 | |
| 1. One or more of the replies doesn't comply with its schema.
 | |
| 2. One or more of the commands in COMMANDS DOCS doesn't have the reply_schema field (with --fail-missing-reply-schemas)
 | |
| 3. The testsuite didn't execute all of the commands (with --fail-commands-not-all-hit)
 | |
| 
 | |
| Future validations:
 | |
| 1. Fail the script if one or more of the branches of the reply schema (e.g. oneOf, anyOf) was not hit.
 | |
| """
 | |
| 
 | |
| IGNORED_COMMANDS = [
 | |
|     "sync",
 | |
|     "psync",
 | |
|     "monitor",
 | |
|     "subscribe",
 | |
|     "unsubscribe",
 | |
|     "ssubscribe",
 | |
|     "sunsubscribe",
 | |
|     "psubscribe",
 | |
|     "punsubscribe",
 | |
|     "debug",
 | |
|     "pfdebug",
 | |
|     "lolwut",
 | |
| ]
 | |
| 
 | |
| 
 | |
| class Request(object):
 | |
|     """
 | |
|     This class represents a Redis request (AKA command, argv)
 | |
|     """
 | |
|     def __init__(self, f, docs, line_counter):
 | |
|         """
 | |
|         Read lines from `f` (generated by logreqres.c) and populates the argv array
 | |
|         """
 | |
|         self.command = None
 | |
|         self.schema = None
 | |
|         self.argv = []
 | |
| 
 | |
|         while True:
 | |
|             line = f.readline()
 | |
|             line_counter[0] += 1
 | |
|             if not line:
 | |
|                 break
 | |
|             length = int(line)
 | |
|             arg = str(f.read(length))
 | |
|             f.read(2)  # read \r\n
 | |
|             line_counter[0] += 1
 | |
|             if arg == "__argv_end__":
 | |
|                 break
 | |
|             self.argv.append(arg)
 | |
| 
 | |
|         if not self.argv:
 | |
|             return
 | |
| 
 | |
|         self.command = self.argv[0].lower()
 | |
|         doc = docs.get(self.command, {})
 | |
|         if not doc and len(self.argv) > 1:
 | |
|             self.command = f"{self.argv[0].lower()}|{self.argv[1].lower()}"
 | |
|             doc = docs.get(self.command, {})
 | |
| 
 | |
|         if not doc:
 | |
|             self.command = None
 | |
|             return
 | |
| 
 | |
|         self.schema = doc.get("reply_schema")
 | |
| 
 | |
|     def __str__(self):
 | |
|         return json.dumps(self.argv)
 | |
| 
 | |
| 
 | |
| class Response(object):
 | |
|     """
 | |
|     This class represents a Redis response in RESP3
 | |
|     """
 | |
|     def __init__(self, f, line_counter):
 | |
|         """
 | |
|         Read lines from `f` (generated by logreqres.c) and build the JSON representing the response in RESP3
 | |
|         """
 | |
|         self.error = False
 | |
|         self.queued = False
 | |
|         self.json = None
 | |
| 
 | |
|         line = f.readline()[:-2]
 | |
|         line_counter[0] += 1
 | |
|         if line[0] == '+':
 | |
|             self.json = line[1:]
 | |
|             if self.json == "QUEUED":
 | |
|                 self.queued = True
 | |
|         elif line[0] == '-':
 | |
|             self.json = line[1:]
 | |
|             self.error = True
 | |
|         elif line[0] == '$':
 | |
|             self.json = str(f.read(int(line[1:])))
 | |
|             f.read(2)  # read \r\n
 | |
|             line_counter[0] += 1
 | |
|         elif line[0] == ':':
 | |
|             self.json = int(line[1:])
 | |
|         elif line[0] == ',':
 | |
|             self.json = float(line[1:])
 | |
|         elif line[0] == '_':
 | |
|             self.json = None
 | |
|         elif line[0] == '#':
 | |
|             self.json = line[1] == 't'
 | |
|         elif line[0] == '!':
 | |
|             self.json = str(f.read(int(line[1:])))
 | |
|             f.read(2)  # read \r\n
 | |
|             line_counter[0] += 1
 | |
|             self.error = True
 | |
|         elif line[0] == '=':
 | |
|             self.json = str(f.read(int(line[1:])))[4:]   # skip "txt:" or "mkd:"
 | |
|             f.read(2)  # read \r\n
 | |
|             line_counter[0] += 1 + self.json.count("\r\n")
 | |
|         elif line[0] == '(':
 | |
|             self.json = line[1:]  # big-number is actually a string
 | |
|         elif line[0] in ['*', '~', '>']:  # unfortunately JSON doesn't tell the difference between a list and a set
 | |
|             self.json = []
 | |
|             count = int(line[1:])
 | |
|             for i in range(count):
 | |
|                 ele = Response(f, line_counter)
 | |
|                 self.json.append(ele.json)
 | |
|         elif line[0] in ['%', '|']:
 | |
|             self.json = {}
 | |
|             count = int(line[1:])
 | |
|             for i in range(count):
 | |
|                 field = Response(f, line_counter)
 | |
|                 # Redis allows fields to be non-strings but JSON doesn't.
 | |
|                 # Luckily, for any kind of response we can validate, the fields are
 | |
|                 # always strings (example: XINFO STREAM)
 | |
|                 # The reason we can't always convert to string is because of DEBUG PROTOCOL MAP
 | |
|                 # which anyway doesn't have a schema
 | |
|                 if isinstance(field.json, str):
 | |
|                     field = field.json
 | |
|                 value = Response(f, line_counter)
 | |
|                 self.json[field] = value.json
 | |
|             if line[0] == '|':
 | |
|                 # We don't care abou the attributes, read the real response
 | |
|                 real_res = Response(f, line_counter)
 | |
|                 self.__dict__.update(real_res.__dict__)
 | |
| 
 | |
| 
 | |
|     def __str__(self):
 | |
|         return json.dumps(self.json)
 | |
| 
 | |
| 
 | |
| def process_file(docs, path):
 | |
|     """
 | |
|     This function processes a single filegenerated by logreqres.c
 | |
|     """
 | |
|     line_counter = [0]  # A list with one integer: to force python to pass it by reference
 | |
|     command_counter = dict()
 | |
| 
 | |
|     print(f"Processing {path} ...")
 | |
| 
 | |
|     # Convert file to StringIO in order to minimize IO operations
 | |
|     with open(path, "r", newline="\r\n", encoding="latin-1") as f:
 | |
|         content = f.read()
 | |
|     
 | |
|     with io.StringIO(content) as fakefile:
 | |
|         while True:
 | |
|             try:
 | |
|                 req = Request(fakefile, docs, line_counter)
 | |
|                 if not req.argv:
 | |
|                     # EOF
 | |
|                     break
 | |
|                 res = Response(fakefile, line_counter)
 | |
|             except json.decoder.JSONDecodeError as err:
 | |
|                 print(f"JSON decoder error while processing {path}:{line_counter[0]}: {err}")
 | |
|                 print(traceback.format_exc())
 | |
|                 raise
 | |
|             except Exception as err:
 | |
|                 print(f"General error while processing {path}:{line_counter[0]}: {err}")
 | |
|                 print(traceback.format_exc())
 | |
|                 raise
 | |
| 
 | |
|             if not req.command:
 | |
|                 # Unknown command
 | |
|                 continue
 | |
| 
 | |
|             command_counter[req.command] = command_counter.get(req.command, 0) + 1
 | |
| 
 | |
|             if res.error or res.queued:
 | |
|                 continue
 | |
| 
 | |
|             try:
 | |
|                 jsonschema.validate(instance=res.json, schema=req.schema, cls=schema_validator)
 | |
|             except (jsonschema.ValidationError, jsonschema.exceptions.SchemaError) as err:
 | |
|                 print(f"JSON schema validation error on {path}: {err}")
 | |
|                 print(f"argv: {req.argv}")
 | |
|                 try:
 | |
|                     print(f"Response: {res}")
 | |
|                 except UnicodeDecodeError as err:
 | |
|                    print("Response: (unprintable)")
 | |
|                 print(f"Schema: {json.dumps(req.schema, indent=2)}")
 | |
|                 print(traceback.format_exc())
 | |
|                 raise
 | |
| 
 | |
|     return command_counter
 | |
| 
 | |
| 
 | |
| def fetch_schemas(cli, port, args, docs):
 | |
|     redis_proc = subprocess.Popen(args, stdout=subprocess.PIPE)
 | |
| 
 | |
|     while True:
 | |
|         try:
 | |
|             print('Connecting to Redis...')
 | |
|             r = redis.Redis(port=port)
 | |
|             r.ping()
 | |
|             break
 | |
|         except Exception as e:
 | |
|             time.sleep(0.1)
 | |
|             pass
 | |
|     print('Connected')
 | |
| 
 | |
|     cli_proc = subprocess.Popen([cli, '-p', str(port), '--json', 'command', 'docs'], stdout=subprocess.PIPE)
 | |
|     stdout, stderr = cli_proc.communicate()
 | |
|     docs_response = json.loads(stdout)
 | |
| 
 | |
|     for name, doc in docs_response.items():
 | |
|         if "subcommands" in doc:
 | |
|             for subname, subdoc in doc["subcommands"].items():
 | |
|                 docs[subname] = subdoc
 | |
|         else:
 | |
|             docs[name] = doc
 | |
| 
 | |
|     redis_proc.terminate()
 | |
|     redis_proc.wait()
 | |
| 
 | |
| 
 | |
| if __name__ == '__main__':
 | |
|     # Figure out where the sources are
 | |
|     srcdir = os.path.abspath(os.path.dirname(os.path.abspath(__file__)) + "/../src")
 | |
|     testdir = os.path.abspath(os.path.dirname(os.path.abspath(__file__)) + "/../tests")
 | |
| 
 | |
|     parser = argparse.ArgumentParser()
 | |
|     parser.add_argument('--server', type=str, default='%s/redis-server' % srcdir)
 | |
|     parser.add_argument('--port', type=int, default=6534)
 | |
|     parser.add_argument('--cli', type=str, default='%s/redis-cli' % srcdir)
 | |
|     parser.add_argument('--module', type=str, action='append', default=[])
 | |
|     parser.add_argument('--verbose', action='store_true')
 | |
|     parser.add_argument('--fail-commands-not-all-hit', action='store_true')
 | |
|     parser.add_argument('--fail-missing-reply-schemas', action='store_true')
 | |
|     args = parser.parse_args()
 | |
| 
 | |
|     docs = dict()
 | |
| 
 | |
|     # Fetch schemas from a Redis instance
 | |
|     print('Starting Redis server')
 | |
|     redis_args = [args.server, '--port', str(args.port)]
 | |
|     for module in args.module:
 | |
|         redis_args += ['--loadmodule', 'tests/modules/%s.so' % module]
 | |
| 
 | |
|     fetch_schemas(args.cli, args.port, redis_args, docs)
 | |
| 
 | |
|     missing_schema = [k for k, v in docs.items()
 | |
|                       if "reply_schema" not in v and k not in IGNORED_COMMANDS]
 | |
|     if missing_schema:
 | |
|         print("WARNING! The following commands are missing a reply_schema:")
 | |
|         for k in sorted(missing_schema):
 | |
|             print(f"  {k}")
 | |
|         if args.fail_missing_reply_schemas:
 | |
|             print("ERROR! at least one command does not have a reply_schema")
 | |
|             sys.exit(1)
 | |
| 
 | |
|     # Fetch schemas from a sentinel
 | |
|     print('Starting Redis sentinel')
 | |
| 
 | |
|     # Sentinel needs a config file to start
 | |
|     config_file = "tmpsentinel.conf"
 | |
|     open(config_file, 'a').close()
 | |
| 
 | |
|     sentinel_args = [args.server, config_file, '--port', str(args.port), "--sentinel"]
 | |
|     fetch_schemas(args.cli, args.port, sentinel_args, docs)
 | |
|     os.unlink(config_file)
 | |
| 
 | |
|     start = time.time()
 | |
| 
 | |
|     # Obtain all the files toprocesses
 | |
|     paths = []
 | |
|     for path in glob.glob('%s/tmp/*/*.reqres' % testdir):
 | |
|         paths.append(path)
 | |
| 
 | |
|     for path in glob.glob('%s/cluster/tmp/*/*.reqres' % testdir):
 | |
|         paths.append(path)
 | |
| 
 | |
|     for path in glob.glob('%s/sentinel/tmp/*/*.reqres' % testdir):
 | |
|         paths.append(path)
 | |
| 
 | |
|     counter = collections.Counter()
 | |
|     # Spin several processes to handle the files in parallel
 | |
|     with multiprocessing.Pool(multiprocessing.cpu_count()) as pool:
 | |
|         func = partial(process_file, docs)
 | |
|         # pool.map blocks until all the files have been processed
 | |
|         for result in pool.map(func, paths):
 | |
|             counter.update(result)
 | |
|     command_counter = dict(counter)
 | |
| 
 | |
|     elapsed = time.time() - start
 | |
|     print(f"Done. ({timedelta(seconds=elapsed)})")
 | |
|     print("Hits per command:")
 | |
|     for k, v in sorted(command_counter.items()):
 | |
|         print(f"  {k}: {v}")
 | |
|     # We don't care about SENTINEL commands
 | |
|     not_hit = set(filter(lambda x: not x.startswith("sentinel"),
 | |
|                   set(docs.keys()) - set(command_counter.keys()) - set(IGNORED_COMMANDS)))
 | |
|     if not_hit:
 | |
|         if args.verbose:
 | |
|             print("WARNING! The following commands were not hit at all:")
 | |
|             for k in sorted(not_hit):
 | |
|                 print(f"  {k}")
 | |
|         if args.fail_commands_not_all_hit:
 | |
|             print("ERROR! at least one command was not hit by the tests")
 | |
|             sys.exit(1)
 | |
| 
 |