--- /dev/null
+#!/usr/bin/env python3
+"""
+# client_server.py
+#
+# Test fio's client/server mode.
+#
+# USAGE
+# see python3 client_server.py --help
+#
+# EXAMPLES
+# python3 t/client_server.py
+# python3 t/client_server.py -f ./fio
+#
+# REQUIREMENTS
+# Python 3.6
+#
+# This will start fio server instances listening on the interfaces below and
+# will break if any ports are already occupied.
+#
+#
+"""
+import os
+import sys
+import time
+import locale
+import logging
+import argparse
+import tempfile
+import subprocess
+import configparser
+from pathlib import Path
+from fiotestlib import FioJobCmdTest, run_fio_tests
+
+
+SERVER_LIST = [
+ ",8765",
+ ",8766",
+ ",8767",
+ ",8768",
+ ]
+
+PIDFILE_LIST = []
+
+class ClientServerTest(FioJobCmdTest):
+ """
+ Client/sever test class.
+ """
+
+ def setup(self, parameters):
+ """Setup a test."""
+
+ fio_args = [
+ f"--output={self.filenames['output']}",
+ f"--output-format={self.fio_opts['output-format']}",
+ ]
+ for server in self.fio_opts['servers']:
+ option = f"--client={server['client']}"
+ fio_args.append(option)
+ fio_args.append(server['jobfile'])
+
+ super().setup(fio_args)
+
+
+
+class ClientServerTestGlobalSingle(ClientServerTest):
+ """
+ Client/sever test class.
+ One server connection only.
+ The job file may or may not have a global section.
+ """
+
+ def check_result(self):
+ super().check_result()
+
+ config = configparser.ConfigParser(allow_no_value=True)
+ config.read(self.fio_opts['servers'][0]['jobfile'])
+
+ if not config.has_section('global'):
+ if len(self.json_data['global options']) > 0:
+ self.failure_reason = f"{self.failure_reason} non-empty 'global options' dictionary found with no global section in job file."
+ self.passed = False
+ return
+
+ if len(self.json_data['global options']) == 0:
+ self.failure_reason = f"{self.failure_reason} empty 'global options' dictionary found with no global section in job file."
+ self.passed = False
+
+ # Now make sure job file global section matches 'global options'
+ # in JSON output
+ job_file_global = dict(config['global'])
+ for key, value in job_file_global.items():
+ if value is None:
+ job_file_global[key] = ""
+ if job_file_global != self.json_data['global options']:
+ self.failure_reason = f"{self.failure_reason} 'global options' dictionary does not match global section in job file."
+ self.passed = False
+
+
+class ClientServerTestGlobalMultiple(ClientServerTest):
+ """
+ Client/sever test class.
+ Multiple server connections.
+ Job files may or may not have a global section.
+ """
+
+ def check_result(self):
+ super().check_result()
+
+ #
+ # For each job file, check if it has a global section
+ # If so, make sure the 'global options' array has
+ # as element for it.
+ # At the end, make sure the total number of elements matches the number
+ # of job files with global sections.
+ #
+
+ global_sections = 0
+ for server in self.fio_opts['servers']:
+ config = configparser.ConfigParser(allow_no_value=True)
+ config.read(server['jobfile'])
+
+ if not config.has_section('global'):
+ continue
+
+ global_sections += 1
+
+ # this can only parse one server spec format
+ [hostname, port] = server['client'].split(',')
+
+ match = None
+ for global_opts in self.json_data['global options']:
+ if 'hostname' not in global_opts:
+ continue
+ if 'port' not in global_opts:
+ continue
+ if global_opts['hostname'] == hostname and int(global_opts['port']) == int(port):
+ match = global_opts
+ break
+
+ if not match:
+ self.failure_reason = f"{self.failure_reason} matching 'global options' element not found for {hostname}, {port}."
+ self.passed = False
+ continue
+
+ del match['hostname']
+ del match['port']
+
+ # Now make sure job file global section matches 'global options'
+ # in JSON output
+ job_file_global = dict(config['global'])
+ for key, value in job_file_global.items():
+ if value is None:
+ job_file_global[key] = ""
+ if job_file_global != match:
+ self.failure_reason += " 'global options' dictionary does not match global section in job file."
+ self.passed = False
+ else:
+ logging.debug("Job file global section matches 'global options' array element %s", server['client'])
+
+ if global_sections != len(self.json_data['global options']):
+ self.failure_reason = f"{self.failure_reason} mismatched number of elements in 'global options' array."
+ self.passed = False
+ else:
+ logging.debug("%d elements in global options array as expected", global_sections)
+
+
+class ClientServerTestAllClientsLat(ClientServerTest):
+ """
+ Client/sever test class.
+ Make sure the "All clients" job has latency percentile data.
+ Assumes that a job named 'test' is run with no global section.
+ Only check read data.
+ """
+
+ def check_result(self):
+ super().check_result()
+
+ config = configparser.ConfigParser(allow_no_value=True)
+ config.read(self.fio_opts['servers'][0]['jobfile'])
+
+ lats = { 'clat': True, 'lat': False, 'slat': False }
+ for key in lats:
+ opt = f"{key}_percentiles"
+ if opt in config.options('test'):
+ lats[key] = config.getboolean('test', opt)
+ logging.debug("%s set to %s", opt, lats[key])
+
+ all_clients = None
+ client_stats = self.json_data['client_stats']
+ for client in client_stats:
+ if client['jobname'] == "All clients":
+ all_clients = client
+ break
+
+ if not all_clients:
+ self.failure_reason = f"{self.failure_reason} Could not find 'All clients' output"
+ self.passed = False
+
+ for key, value in lats.items():
+ if value:
+ if 'percentile' not in all_clients['read'][f"{key}_ns"]:
+ self.failure_reason += f" {key} percentiles not found"
+ self.passed = False
+ break
+
+ logging.debug("%s percentiles found as expected", key)
+ else:
+ if 'percentile' in all_clients['read'][f"{key}_ns"]:
+ self.failure_reason += f" {key} percentiles found unexpectedly"
+ self.passed = False
+ break
+
+ logging.debug("%s percentiles appropriately not found", key)
+
+
+
+TEST_LIST = [
+ { # Smoke test
+ "test_id": 1,
+ "fio_opts": {
+ "output-format": "json",
+ "servers": [
+ {
+ "client" : 0, # index into the SERVER_LIST array
+ "jobfile": "test01.fio",
+ },
+ ]
+ },
+ "test_class": ClientServerTest,
+ },
+ { # try another client
+ "test_id": 2,
+ "fio_opts": {
+ "output-format": "json",
+ "servers": [
+ {
+ "client" : 1,
+ "jobfile": "test01.fio",
+ },
+ ]
+ },
+ "test_class": ClientServerTest,
+ },
+ { # single client global section
+ "test_id": 3,
+ "fio_opts": {
+ "output-format": "json",
+ "servers": [
+ {
+ "client" : 2,
+ "jobfile": "test01.fio",
+ },
+ ]
+ },
+ "test_class": ClientServerTestGlobalSingle,
+ },
+ { # single client no global section
+ "test_id": 4,
+ "fio_opts": {
+ "output-format": "json",
+ "servers": [
+ {
+ "client" : 3,
+ "jobfile": "test04-noglobal.fio",
+ },
+ ]
+ },
+ "test_class": ClientServerTestGlobalSingle,
+ },
+ { # multiple clients, some with global, some without
+ "test_id": 5,
+ "fio_opts": {
+ "output-format": "json",
+ "servers": [
+ {
+ "client" : 0,
+ "jobfile": "test04-noglobal.fio",
+ },
+ {
+ "client" : 1,
+ "jobfile": "test01.fio",
+ },
+ {
+ "client" : 2,
+ "jobfile": "test04-noglobal.fio",
+ },
+ {
+ "client" : 3,
+ "jobfile": "test01.fio",
+ },
+ ]
+ },
+ "test_class": ClientServerTestGlobalMultiple,
+ },
+ { # multiple clients, all with global sections
+ "test_id": 6,
+ "fio_opts": {
+ "output-format": "json",
+ "servers": [
+ {
+ "client" : 0,
+ "jobfile": "test01.fio",
+ },
+ {
+ "client" : 1,
+ "jobfile": "test01.fio",
+ },
+ {
+ "client" : 2,
+ "jobfile": "test01.fio",
+ },
+ {
+ "client" : 3,
+ "jobfile": "test01.fio",
+ },
+ ]
+ },
+ "test_class": ClientServerTestGlobalMultiple,
+ },
+ { # Enable submission latency
+ "test_id": 7,
+ "fio_opts": {
+ "output-format": "json",
+ "servers": [
+ {
+ "client" : 0,
+ "jobfile": "test07-slat.fio",
+ },
+ {
+ "client" : 1,
+ "jobfile": "test07-slat.fio",
+ },
+ ]
+ },
+ "test_class": ClientServerTestAllClientsLat,
+ },
+ { # Enable completion latency
+ "test_id": 8,
+ "fio_opts": {
+ "output-format": "json",
+ "servers": [
+ {
+ "client" : 0,
+ "jobfile": "test08-clat.fio",
+ },
+ {
+ "client" : 1,
+ "jobfile": "test08-clat.fio",
+ },
+ ]
+ },
+ "test_class": ClientServerTestAllClientsLat,
+ },
+ { # Enable total latency
+ "test_id": 9,
+ "fio_opts": {
+ "output-format": "json",
+ "servers": [
+ {
+ "client" : 0,
+ "jobfile": "test09-lat.fio",
+ },
+ {
+ "client" : 1,
+ "jobfile": "test09-lat.fio",
+ },
+ ]
+ },
+ "test_class": ClientServerTestAllClientsLat,
+ },
+ { # Disable completion latency
+ "test_id": 10,
+ "fio_opts": {
+ "output-format": "json",
+ "servers": [
+ {
+ "client" : 0,
+ "jobfile": "test10-noclat.fio",
+ },
+ {
+ "client" : 1,
+ "jobfile": "test10-noclat.fio",
+ },
+ ]
+ },
+ "test_class": ClientServerTestAllClientsLat,
+ },
+ { # Enable submission, completion, total latency
+ "test_id": 11,
+ "fio_opts": {
+ "output-format": "json",
+ "servers": [
+ {
+ "client" : 0,
+ "jobfile": "test11-alllat.fio",
+ },
+ {
+ "client" : 1,
+ "jobfile": "test11-alllat.fio",
+ },
+ ]
+ },
+ "test_class": ClientServerTestAllClientsLat,
+ },
+]
+
+
+def parse_args():
+ """Parse command-line arguments."""
+
+ parser = argparse.ArgumentParser()
+ parser.add_argument('-d', '--debug', help='Enable debug messages', action='store_true')
+ parser.add_argument('-f', '--fio', help='path to file executable (e.g., ./fio)')
+ parser.add_argument('-a', '--artifact-root', help='artifact root directory')
+ parser.add_argument('-s', '--skip', nargs='+', type=int,
+ help='list of test(s) to skip')
+ parser.add_argument('-o', '--run-only', nargs='+', type=int,
+ help='list of test(s) to run, skipping all others')
+ args = parser.parse_args()
+
+ return args
+
+
+def start_servers(fio_path, servers=SERVER_LIST):
+ """Start servers for our tests."""
+
+ for server in servers:
+ tmpfile = tempfile.mktemp()
+ cmd = [fio_path, f"--server={server}", f"--daemonize={tmpfile}"]
+ cmd_result = subprocess.run(cmd, capture_output=True, check=False,
+ encoding=locale.getpreferredencoding())
+ if cmd_result.returncode != 0:
+ logging.error("Unable to start server on %s: %s", server, cmd_result.stderr)
+ return False
+
+ logging.debug("Started server %s", server)
+ PIDFILE_LIST.append(tmpfile)
+
+ return True
+
+
+def stop_servers(pidfiles=PIDFILE_LIST):
+ """Stop running fio server invocations."""
+
+ for pidfile in pidfiles:
+ with open(pidfile, "r", encoding=locale.getpreferredencoding()) as file:
+ pid = file.read().strip()
+
+ cmd = ["kill", f"{pid}"]
+ cmd_result = subprocess.run(cmd, capture_output=True, check=False,
+ encoding=locale.getpreferredencoding())
+ if cmd_result.returncode != 0:
+ logging.error("Unable to kill server with PID %s: %s", pid, cmd_result.stderr)
+ return False
+ logging.debug("Sent stop signal to PID %s", pid)
+
+ return True
+
+
+def main():
+ """Run tests for fio's client/server mode."""
+
+ args = parse_args()
+
+ if args.debug:
+ logging.basicConfig(level=logging.DEBUG)
+ else:
+ logging.basicConfig(level=logging.INFO)
+
+ artifact_root = args.artifact_root if args.artifact_root else \
+ f"client_server-test-{time.strftime('%Y%m%d-%H%M%S')}"
+ os.mkdir(artifact_root)
+ print(f"Artifact directory is {artifact_root}")
+
+ if args.fio:
+ fio_path = str(Path(args.fio).absolute())
+ else:
+ fio_path = os.path.join(os.path.dirname(__file__), '../fio')
+ print(f"fio path is {fio_path}")
+
+ if not start_servers(fio_path):
+ sys.exit(1)
+ print("Servers started")
+
+ job_path = os.path.join(os.path.dirname(__file__), "client_server")
+ for test in TEST_LIST:
+ opts = test['fio_opts']
+ for server in opts['servers']:
+ server['client'] = SERVER_LIST[server['client']]
+ server['jobfile'] = os.path.join(job_path, server['jobfile'])
+
+ test_env = {
+ 'fio_path': fio_path,
+ 'fio_root': str(Path(__file__).absolute().parent.parent),
+ 'artifact_root': artifact_root,
+ 'basename': 'client_server',
+ }
+
+ _, failed, _ = run_fio_tests(TEST_LIST, test_env, args)
+
+ stop_servers()
+ sys.exit(failed)
+
+if __name__ == '__main__':
+ main()