1# Copyright 2018 The Chromium OS Authors. All rights reserved. 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5import dbus 6import gzip 7import logging 8import os 9import subprocess 10import shutil 11import tempfile 12 13from autotest_lib.client.bin import test 14from autotest_lib.client.common_lib import error 15from autotest_lib.client.common_lib import file_utils 16from autotest_lib.client.cros import debugd_util 17 18import archiver 19import configurator 20import helpers 21import fake_printer 22import log_reader 23import multithreaded_processor 24 25# Timeout for printing documents in seconds 26_FAKE_PRINTER_TIMEOUT = 200 27 28# Prefix for CUPS printer name 29_FAKE_PRINTER_ID = 'FakePrinter' 30 31# First port number to use, this test uses consecutive ports numbers, 32# different for every PPD file 33_FIRST_PORT_NUMBER = 9000 34 35# Values are from platform/system_api/dbus/debugd/dbus-constants.h. 36_CUPS_SUCCESS = 0 37 38class platform_PrinterPpds(test.test): 39 """ 40 This test gets a list of PPD files and a list of test documents. It tries 41 to add printer using each PPD file and to print all test documents on 42 every printer created this way. Becasue the number of PPD files to test can 43 be large (more then 3K), PPD files are tested simultaneously in many 44 threads. 45 46 """ 47 version = 3 48 49 50 def _get_filenames_from_PPD_indexes(self): 51 """ 52 It returns all PPD filenames from SCS server. 53 54 @returns a list of PPD filenames without duplicates 55 56 """ 57 # extracts PPD filenames from all 20 index files (in parallel) 58 outputs = self._processor.run(helpers.get_filenames_from_PPD_index, 20) 59 # joins obtained lists and performs deduplication 60 ppd_files = set() 61 for output in outputs: 62 ppd_files.update(output) 63 return list(ppd_files) 64 65 66 def _calculate_full_path(self, path): 67 """ 68 Converts path given as a parameter to absolute path. 69 70 @param path: a path set in configuration (relative, absolute or None) 71 72 @returns absolute path or None if the input parameter was None 73 74 """ 75 if path is None or os.path.isabs(path): 76 return path 77 path_current = os.path.dirname(os.path.realpath(__file__)) 78 return os.path.join(path_current, path) 79 80 81 def initialize(self, 82 path_docs, 83 path_ppds, 84 path_digests=None, 85 debug_mode=False, 86 threads_count=8): 87 """ 88 @param path_docs: path to local directory with documents to print 89 @param path_ppds: path to local directory with PPD files to test; 90 the directory is supposed to be compressed as .tar.xz. 91 @param path_digests: path to local directory with digests files for 92 test documents; if None is set then content of printed 93 documents is not verified 94 @param debug_mode: if set to True, then the autotest temporarily 95 remounts the root partition in R/W mode and changes CUPS 96 configuration, what allows to extract pipelines for all tested 97 PPDs and rerun the outside CUPS 98 @param threads_count: number of threads to use 99 100 """ 101 # Calculates absolute paths for all parameters 102 self._location_of_test_docs = self._calculate_full_path(path_docs) 103 self._location_of_PPD_files = self._calculate_full_path(path_ppds) 104 location_of_digests_files = self._calculate_full_path(path_digests) 105 106 # This object is used for running tasks in many threads simultaneously 107 self._processor = multithreaded_processor.MultithreadedProcessor( 108 threads_count) 109 110 # This object is responsible for parsing CUPS logs 111 self._log_reader = log_reader.LogReader() 112 113 # This object is responsible for the system configuration 114 self._configurator = configurator.Configurator() 115 self._configurator.configure(debug_mode) 116 117 # Read list of test documents 118 self._docs = helpers.list_entries_from_directory( 119 path=self._location_of_test_docs, 120 with_suffixes=('.pdf'), 121 nonempty_results=True, 122 include_directories=False) 123 124 # Load the list of PPD files to omit 125 do_not_test_path = self._calculate_full_path('do_not_test.txt') 126 do_not_test_set = set(helpers.load_lines_from_file(do_not_test_path)) 127 128 # Unpack an archive with the PPD files: 129 path_archive = self._location_of_PPD_files + '.tar.xz' 130 path_target_dir = self._calculate_full_path('.') 131 file_utils.rm_dir_if_exists(os.path.join(path_target_dir, path_ppds)) 132 subprocess.call(['tar', 'xJf', path_archive, '-C', path_target_dir]) 133 # Load PPD files from the unpacked directory 134 self._ppds = helpers.list_entries_from_directory( 135 path=self._location_of_PPD_files, 136 with_suffixes=('.ppd', '.ppd.gz'), 137 nonempty_results=True, 138 include_directories=False) 139 # Remove from the list all PPD files to omit and sort it 140 self._ppds = list(set(self._ppds) - do_not_test_set) 141 self._ppds.sort() 142 143 # Load digests files 144 self._digests = dict() 145 self._sizes = dict() 146 if location_of_digests_files is None: 147 for doc_name in self._docs: 148 self._digests[doc_name] = dict() 149 self._sizes[doc_name] = dict() 150 else: 151 path_denylist = os.path.join(location_of_digests_files, 152 'denylist.txt') 153 denylist = helpers.load_lines_from_file(path_denylist) 154 for doc_name in self._docs: 155 digests_name = doc_name + '.digests' 156 path = os.path.join(location_of_digests_files, digests_name) 157 digests, sizes = helpers.parse_digests_file(path, denylist) 158 self._digests[doc_name] = digests 159 self._sizes[doc_name] = sizes 160 161 # Prepare a working directory for pipelines 162 if debug_mode: 163 self._pipeline_dir = tempfile.mkdtemp(dir='/tmp') 164 else: 165 self._pipeline_dir = None 166 167 168 def cleanup(self): 169 """ 170 Cleanup. 171 172 """ 173 # Resore previous system settings 174 self._configurator.restore() 175 176 # Delete directories with PPD files 177 path_ppds = self._calculate_full_path('ppds_100') 178 file_utils.rm_dir_if_exists(path_ppds) 179 path_ppds = self._calculate_full_path('ppds_all') 180 file_utils.rm_dir_if_exists(path_ppds) 181 182 # Delete pipeline working directory 183 if self._pipeline_dir is not None: 184 file_utils.rm_dir_if_exists(self._pipeline_dir) 185 186 187 def run_once(self, path_outputs=None): 188 """ 189 This is the main test function. It runs the testing procedure for 190 every PPD file. Tests are run simultaneously in many threads. 191 192 @param path_outputs: if it is not None, raw outputs sent 193 to printers are dumped here; the directory is overwritten if 194 already exists (is deleted and recreated) 195 196 @raises error.TestFail if at least one of the tests failed 197 198 """ 199 # Set directory for output documents 200 self._path_output_directory = self._calculate_full_path(path_outputs) 201 if self._path_output_directory is not None: 202 # Delete whole directory if already exists 203 file_utils.rm_dir_if_exists(self._path_output_directory) 204 # Create archivers 205 self._archivers = dict() 206 for doc_name in self._docs: 207 path_for_archiver = os.path.join(self._path_output_directory, 208 doc_name) 209 self._archivers[doc_name] = archiver.Archiver(path_for_archiver, 210 self._ppds, 50) 211 # A place for new digests 212 self._new_digests = dict() 213 self._new_sizes = dict() 214 for doc_name in self._docs: 215 self._new_digests[doc_name] = dict() 216 self._new_sizes[doc_name] = dict() 217 218 # Runs tests for all PPD files (in parallel) 219 outputs = self._processor.run(self._thread_test_PPD, len(self._ppds)) 220 221 # Analyses tests' outputs, prints a summary report and builds a list 222 # of PPD filenames that failed 223 failures = [] 224 for i, output in enumerate(outputs): 225 ppd_file = self._ppds[i] 226 if output != True: 227 failures.append(ppd_file) 228 else: 229 output = 'OK' 230 line = "%s: %s" % (ppd_file, output) 231 logging.info(line) 232 233 # Calculate digests files for output documents (if dumped) 234 if self._path_output_directory is not None: 235 for doc_name in self._docs: 236 path = os.path.join(self._path_output_directory, 237 doc_name + '.digests') 238 helpers.save_digests_file(path, self._new_digests[doc_name], 239 self._new_sizes[doc_name], failures) 240 241 # Raises an exception if at least one test failed 242 if len(failures) > 0: 243 failures.sort() 244 raise error.TestFail( 245 'Test failed for %d PPD files: %s' 246 % (len(failures), ', '.join(failures)) ) 247 248 249 def _thread_test_PPD(self, task_id): 250 """ 251 Runs a test procedure for single PPD file. 252 253 It retrieves assigned PPD file and run for it a test procedure. 254 255 @param task_id: an index of the PPD file in self._ppds 256 257 @returns True when the test was passed or description of the error 258 (string) if the test failed 259 260 """ 261 # Gets content of the PPD file 262 try: 263 ppd_file = self._ppds[task_id] 264 if self._location_of_PPD_files is None: 265 # Downloads PPD file from the SCS server 266 ppd_content = helpers.download_PPD_file(ppd_file) 267 else: 268 # Reads PPD file from local filesystem 269 path_ppd = os.path.join(self._location_of_PPD_files, ppd_file) 270 with open(path_ppd, 'rb') as ppd_file_descriptor: 271 ppd_content = ppd_file_descriptor.read() 272 except BaseException as e: 273 return 'MISSING PPD: ' + str(e) 274 275 # Runs the test procedure 276 try: 277 port = _FIRST_PORT_NUMBER + task_id 278 self._PPD_test_procedure(ppd_file, ppd_content, port) 279 except BaseException as e: 280 return 'FAIL: ' + str(e) 281 282 return True 283 284 285 def _PPD_test_procedure(self, ppd_name, ppd_content, port): 286 """ 287 Test procedure for single PPD file. 288 289 It tries to run the following steps: 290 1. Starts an instance of FakePrinter 291 2. Configures CUPS printer 292 3. For each test document run the following steps: 293 3a. Sends tests documents to the CUPS printer 294 3b. Fetches the raw document from the FakePrinter 295 3c. Parse CUPS logs and check for any errors 296 3d. If self._pipeline_dir is set, extract the executed CUPS 297 pipeline, rerun it in bash console and verify every step and 298 final output 299 3e. If self._path_output_directory is set, save the raw document 300 and all intermediate steps in the provided directory 301 3f. If the digest is available, verify a digest of an output 302 documents 303 4. Removes CUPS printer and stops FakePrinter 304 If the test fails this method throws an exception. 305 306 @param ppd_name: a name of the PPD file 307 @param ppd_content: a content of the PPD file 308 @param port: a port for the printer 309 310 @throws Exception when the test fails 311 312 """ 313 # Create work directory for external pipelines and save the PPD file 314 # there (if needed) 315 path_ppd = None 316 if self._pipeline_dir is not None: 317 path_pipeline_ppd_dir = os.path.join(self._pipeline_dir, ppd_name) 318 os.makedirs(path_pipeline_ppd_dir) 319 path_ppd = os.path.join(path_pipeline_ppd_dir, ppd_name) 320 with open(path_ppd, 'wb') as file_ppd: 321 file_ppd.write(ppd_content) 322 if path_ppd.endswith('.gz'): 323 subprocess.call(['gzip', '-d', path_ppd]) 324 path_ppd = path_ppd[0:-3] 325 326 try: 327 # Starts the fake printer 328 with fake_printer.FakePrinter(port) as printer: 329 330 # Add a CUPS printer manually with given ppd file 331 cups_printer_id = '%s_at_%05d' % (_FAKE_PRINTER_ID,port) 332 result = debugd_util.iface().CupsAddManuallyConfiguredPrinter( 333 cups_printer_id, 334 'socket://127.0.0.1:%d' % port, 335 dbus.ByteArray(ppd_content)) 336 if result != _CUPS_SUCCESS: 337 raise Exception('valid_config - Could not setup valid ' 338 'printer %d' % result) 339 340 # Prints all test documents 341 try: 342 for doc_name in self._docs: 343 # Full path to the test document 344 path_doc = os.path.join( 345 self._location_of_test_docs, doc_name) 346 # Sends test document to printer 347 argv = ['lp', '-d', cups_printer_id] 348 argv += [path_doc] 349 subprocess.call(argv) 350 # Prepare a workdir for the pipeline (if needed) 351 path_pipeline_workdir_temp = None 352 if self._pipeline_dir is not None: 353 path_pipeline_workdir = os.path.join( 354 path_pipeline_ppd_dir, doc_name) 355 path_pipeline_workdir_temp = os.path.join( 356 path_pipeline_workdir, 'temp') 357 os.makedirs(path_pipeline_workdir_temp) 358 # Gets the output document from the fake printer 359 doc = printer.fetch_document(_FAKE_PRINTER_TIMEOUT) 360 digest = helpers.calculate_digest(doc) 361 # Retrive data from the log file 362 no_errors, logs, pipeline = \ 363 self._log_reader.extract_result( 364 cups_printer_id, path_ppd, path_doc, 365 path_pipeline_workdir_temp) 366 # Archive obtained results in the output directory 367 if self._path_output_directory is not None: 368 self._archivers[doc_name].save_file( 369 ppd_name, '.out', doc, apply_gzip=True) 370 self._archivers[doc_name].save_file( 371 ppd_name, '.log', logs) 372 if pipeline is not None: 373 self._archivers[doc_name].save_file( 374 ppd_name, '.sh', pipeline) 375 # Set new digest 376 self._new_digests[doc_name][ppd_name] = digest 377 self._new_sizes[doc_name][ppd_name] = len(doc) 378 # Fail if any of CUPS filters failed 379 if not no_errors: 380 raise Exception('One of the CUPS filters failed') 381 # Reruns the pipeline and dump intermediate outputs 382 if self._pipeline_dir is not None: 383 self._rerun_whole_pipeline( 384 pipeline, path_pipeline_workdir, 385 ppd_name, doc_name, digest) 386 shutil.rmtree(path_pipeline_workdir) 387 # Check document's digest (if known) 388 if ppd_name in self._digests[doc_name]: 389 digest_expected = self._digests[doc_name][ppd_name] 390 if digest_expected != digest: 391 message = 'Document\'s digest does not match' 392 if ppd_name in self._sizes[doc_name]: 393 message += ', old size: ' + \ 394 str(self._sizes[doc_name][ppd_name]) 395 message += ', new size: ' + str(len(doc)) 396 raise Exception(message) 397 else: 398 # Simple validation 399 if len(doc) < 16: 400 raise Exception('Empty output') 401 finally: 402 # Remove CUPS printer 403 debugd_util.iface().CupsRemovePrinter(cups_printer_id) 404 405 # The fake printer is stopped at the end of "with" statement 406 finally: 407 # Finalize archivers and cleaning 408 if self._path_output_directory is not None: 409 for doc_name in self._docs: 410 self._archivers[doc_name].finalize_prefix(ppd_name) 411 # Clean the pipelines' working directories 412 if self._pipeline_dir is not None: 413 shutil.rmtree(path_pipeline_ppd_dir) 414 415 416 def _rerun_whole_pipeline( 417 self, pipeline, path_workdir, ppd_name, doc_name, digest): 418 """ 419 Reruns the whole pipeline outside CUPS server. 420 421 Reruns a printing pipeline dumped from CUPS. All intermediate outputs 422 are dumped and archived for future analysis. 423 424 @param pipeline: a pipeline as a bash script 425 @param path_workdir: an existing directory to use as working directory 426 @param ppd_name: a filenames prefix used for archivers 427 @param doc_name: a document name, used to select a proper archiver 428 @param digest: an digest of the output produced by CUPS (for comparison) 429 430 @raises Exception in case of any errors 431 432 """ 433 # Save pipeline to a file 434 path_pipeline = os.path.join(path_workdir, 'pipeline.sh') 435 with open(path_pipeline, 'wb') as file_pipeline: 436 file_pipeline.write(pipeline) 437 # Run the pipeline 438 argv = ['/bin/bash', '-e', path_pipeline] 439 ret = subprocess.Popen(argv, cwd=path_workdir).wait() 440 # Find the number of output files 441 i = 1 442 while os.path.isfile(os.path.join(path_workdir, "%d.doc.gz" % i)): 443 i += 1 444 files_count = i-1 445 # Reads the last output (to compare it with the output produced by CUPS) 446 if ret == 0: 447 with gzip.open(os.path.join(path_workdir, 448 "%d.doc.gz" % files_count)) as last_file: 449 content_digest = helpers.calculate_digest(last_file.read()) 450 # Archives all intermediate files (if desired) 451 if self._path_output_directory is not None: 452 for i in range(1,files_count+1): 453 self._archivers[doc_name].move_file(ppd_name, ".err%d" % i, 454 os.path.join(path_workdir, "%d.err" % i)) 455 self._archivers[doc_name].move_file(ppd_name, ".out%d.gz" % i, 456 os.path.join(path_workdir, "%d.doc.gz" % i)) 457 # Validation 458 if ret != 0: 459 raise Exception("A pipeline script returned %d" % ret) 460 if content_digest != digest: 461 raise Exception("The output returned by the pipeline is different" 462 " than the output produced by CUPS") 463