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