1 /*
2  * Copyright (C) 2019 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.android.tradefed.cluster;
17 
18 import com.android.annotations.VisibleForTesting;
19 import com.android.tradefed.config.GlobalConfiguration;
20 import com.android.tradefed.config.Option;
21 import com.android.tradefed.config.OptionClass;
22 import com.android.tradefed.invoker.IInvocationContext;
23 import com.android.tradefed.log.LogUtil.CLog;
24 import com.android.tradefed.result.ILogSaver;
25 import com.android.tradefed.result.LogDataType;
26 import com.android.tradefed.result.LogFile;
27 import com.android.tradefed.result.LogFileSaver;
28 import com.android.tradefed.util.FileUtil;
29 
30 import org.json.JSONException;
31 
32 import java.io.File;
33 import java.io.IOException;
34 import java.io.InputStream;
35 import java.net.MalformedURLException;
36 import java.net.URI;
37 import java.nio.file.FileSystem;
38 import java.nio.file.FileSystems;
39 import java.nio.file.FileVisitOption;
40 import java.nio.file.Files;
41 import java.nio.file.Path;
42 import java.nio.file.Paths;
43 import java.util.ArrayList;
44 import java.util.Comparator;
45 import java.util.HashMap;
46 import java.util.List;
47 import java.util.Map;
48 import java.util.Optional;
49 import java.util.Set;
50 import java.util.TreeMap;
51 import java.util.TreeSet;
52 import java.util.regex.Matcher;
53 import java.util.regex.Pattern;
54 import java.util.stream.Collectors;
55 import java.util.stream.Stream;
56 
57 /** A {@link ILogSaver} class to upload test outputs to TFC. */
58 @OptionClass(alias = "cluster", global_namespace = false)
59 public class ClusterLogSaver implements ILogSaver {
60 
61     /** A name of a text file containing all test output file names. */
62     public static final String FILE_NAMES_FILE_NAME = "FILES";
63 
64     /** A name of a subdirectory containing all files generated by host process. */
65     public static final String TOOL_LOG_PATH = "tool-logs";
66 
67     /** File picking strategies. */
68     public static enum FilePickingStrategy {
69         PICK_LAST,
70         PICK_FIRST
71     }
72 
73     @Option(name = "root-dir", description = "A root directory", mandatory = true)
74     private File mRootDir;
75 
76     @Option(name = "request-id", description = "A request ID", mandatory = true)
77     private String mRequestId;
78 
79     @Option(name = "command-id", description = "A command ID", mandatory = true)
80     private String mCommandId;
81 
82     @Option(name = "attempt-id", description = "A command attempt ID", mandatory = true)
83     private String mAttemptId;
84 
85     @Option(
86             name = "output-file-upload-url",
87             description = "URL to upload output files to",
88             mandatory = true)
89     private String mOutputFileUploadUrl;
90 
91     @Option(name = "output-file-pattern", description = "Output file patterns")
92     private List<String> mOutputFilePatterns = new ArrayList<>();
93 
94     @Option(
95             name = "context-file-pattern",
96             description =
97                     "A regex pattern for test context file(s). A test context file is a file to be"
98                             + " used in successive invocations to pass context information.")
99     private String mContextFilePattern = null;
100 
101     @Option(name = "file-picking-strategy", description = "A picking strategy for file(s)")
102     private FilePickingStrategy mFilePickingStrategy = FilePickingStrategy.PICK_LAST;
103 
104     @Option(
105             name = "extra-context-file",
106             description =
107                     "Additional files to include in the context file. "
108                             + "Context file must be a ZIP archive.")
109     private List<String> mExtraContextFiles = new ArrayList<>();
110 
111     @Option(
112             name = "retry-command-line",
113             description =
114                     "A command line to store in test context. This will replace the original"
115                             + " command line in a retry invocation.")
116     private String mRetryCommandLine = null;
117 
118     @Option(
119             name = "output-file-format-pattern",
120             description =
121                     "Format the uploading path by removing each substring of output files "
122                             + "that matches this pattern.")
123     private String mOutputFileFormatPattern = "^android-[^/]+/(results|logs)/[^/]+/?";
124 
125     private File mLogDir;
126     private LogFileSaver mLogFileSaver = null;
127     private IClusterClient mClusterClient = null;
128 
129     @Override
invocationStarted(IInvocationContext context)130     public void invocationStarted(IInvocationContext context) {
131         mLogDir = new File(mRootDir, "logs");
132         mLogFileSaver = new LogFileSaver(mLogDir);
133     }
134 
getRelativePath(Path p)135     private Path getRelativePath(Path p) {
136         return mRootDir.toPath().relativize(p);
137     }
138 
139     /** Returns a Path stream for all files under a directory matching a given pattern. */
140     @SuppressWarnings("StreamResourceLeak")
getPathStream(final File dir, final Pattern pattern)141     private Stream<Path> getPathStream(final File dir, final Pattern pattern) throws IOException {
142         return Files.find(
143                 dir.toPath(),
144                 Integer.MAX_VALUE,
145                 (path, attr) ->
146                         attr.isRegularFile()
147                                 && pattern.matcher(getRelativePath(path).toString()).matches(),
148                 FileVisitOption.FOLLOW_LINKS);
149     }
150 
findFilesRecursively(final File dir, final String regex)151     private Set<File> findFilesRecursively(final File dir, final String regex) {
152         final Pattern pattern = Pattern.compile(regex);
153         try (final Stream<Path> stream = getPathStream(dir, pattern)) {
154             return stream.map(Path::toFile).collect(Collectors.toSet());
155         } catch (IOException e) {
156             throw new RuntimeException("Failed to collect output files", e);
157         }
158     }
159 
getGroupNames(final String regex)160     private Set<String> getGroupNames(final String regex) {
161         final Set<String> names = new TreeSet<>();
162         Matcher m = Pattern.compile("\\(\\?<([a-zA-Z][a-zA-Z0-9]*)>").matcher(regex);
163         while (m.find()) {
164             names.add(m.group(1));
165         }
166         return names;
167     }
168 
169     /**
170      * Find a test context file and collect environment vars if exists.
171      *
172      * <p>If there are multiple matches, it will only collect the first or last one in
173      * lexicographical order according to a given file picking strategy. If a newly collected
174      * environment var already exists in a given map, it will be overridden.
175      *
176      * @param dir a root directory.
177      * @param regex a regex pattern for a context file path. A relative path is used for matching.
178      * @param strategy a file picking strategy.
179      * @param envVars a map for environment vars.
180      * @return a {@link File} object.
181      */
182     @VisibleForTesting
findTestContextFile( final File dir, final String regex, final FilePickingStrategy strategy, final Map<String, String> envVars)183     File findTestContextFile(
184             final File dir,
185             final String regex,
186             final FilePickingStrategy strategy,
187             final Map<String, String> envVars) {
188         final Pattern pattern = Pattern.compile(regex);
189         try (Stream<Path> stream = getPathStream(dir, pattern)) {
190             Optional<Path> op = null;
191             switch (strategy) {
192                 case PICK_FIRST:
193                     op = stream.sorted().findFirst();
194                     break;
195                 case PICK_LAST:
196                     op = stream.sorted(Comparator.reverseOrder()).findFirst();
197                     break;
198             }
199             if (op == null || !op.isPresent()) {
200                 return null;
201             }
202             final Path p = op.get();
203             Set<String> groupNames = getGroupNames(regex);
204             CLog.d("Context var names: %s", groupNames);
205             Path relPath = dir.toPath().relativize(p);
206             Matcher matcher = pattern.matcher(relPath.toString());
207             // One needs to call matches() before calling group() method.
208             matcher.matches();
209             for (final String name : groupNames) {
210                 final String value = matcher.group(name);
211                 if (value == null) {
212                     continue;
213                 }
214                 envVars.put(name, value);
215             }
216             return p.toFile();
217         } catch (IOException e) {
218             throw new RuntimeException("Failed to collect a context file", e);
219         }
220     }
221 
222     /** Determine a file's new path after applying an optional prefix. */
getDestinationPath(String prefix, File file)223     private String getDestinationPath(String prefix, File file) {
224         String filename = file.getName();
225         return prefix == null ? filename : Paths.get(prefix, filename).toString();
226     }
227 
228     /**
229      * Create a text file containing a list of file names.
230      *
231      * @param filenames filenames to write.
232      * @param destFile a {@link File} where to write names to.
233      * @throws IOException if writing fails
234      */
writeFilenamesToFile(Set<String> filenames, File destFile)235     private void writeFilenamesToFile(Set<String> filenames, File destFile) throws IOException {
236         String content = filenames.stream().sorted().collect(Collectors.joining("\n"));
237         FileUtil.writeToFile(content, destFile);
238     }
239 
240     /**
241      * Upload files to mOutputFileUploadUrl.
242      *
243      * @param fileMap a {@link Map} of file and destination path string pairs.
244      * @return a {@link Map} of file and URL pairs.
245      */
uploadFiles(Map<File, String> fileMap, FilePickingStrategy strategy)246     private Map<File, String> uploadFiles(Map<File, String> fileMap, FilePickingStrategy strategy) {
247         // construct a map of unique destination paths and files, to prevent duplicate uploads
248         Map<String, File> destinationMap =
249                 fileMap.entrySet()
250                         .stream()
251                         .sorted(Comparator.comparing(Map.Entry::getKey)) // sort by filename
252                         .collect(
253                                 Collectors.toMap(
254                                         e -> getDestinationPath(e.getValue(), e.getKey()),
255                                         Map.Entry::getKey,
256                                         // use strategy if two files have the same destination
257                                         (first, second) ->
258                                                 strategy == FilePickingStrategy.PICK_FIRST
259                                                         ? first
260                                                         : second));
261         fileMap.keySet().retainAll(destinationMap.values());
262         CLog.i("Collected %d files to upload", fileMap.size());
263         fileMap.keySet().forEach(f -> CLog.i(f.getAbsolutePath()));
264 
265         // Create a file names file.
266         File fileNamesFile = new File(mRootDir, FILE_NAMES_FILE_NAME);
267         try {
268             writeFilenamesToFile(destinationMap.keySet(), fileNamesFile);
269         } catch (IOException e) {
270             CLog.e("Failed to write %s", fileNamesFile.getAbsolutePath());
271         }
272 
273         final TestOutputUploader uploader = getTestOutputUploader();
274         try {
275             uploader.setUploadUrl(mOutputFileUploadUrl);
276         } catch (MalformedURLException e) {
277             throw new RuntimeException("Failed to set upload URL", e);
278         }
279 
280         fileMap.put(fileNamesFile, null);
281         final Map<File, String> fileUrls = new TreeMap<>();
282         int index = 1;
283         for (Map.Entry<File, String> entry : fileMap.entrySet()) {
284             File file = entry.getKey();
285             CLog.i("Uploading file %d of %d: %s", index, fileMap.size(), file.getAbsolutePath());
286             try {
287                 fileUrls.put(file, uploader.uploadFile(file, entry.getValue()));
288             } catch (IOException | RuntimeException e) {
289                 CLog.e("Failed to upload %s: %s", file, e);
290             }
291             index++;
292         }
293         return fileUrls;
294     }
295 
296     /** If the context file is a zip file, will append the specified files to it. */
297     @VisibleForTesting
appendFilesToContext(File contextFile, List<String> filesToAdd)298     void appendFilesToContext(File contextFile, List<String> filesToAdd) {
299         if (filesToAdd.isEmpty()) {
300             return;
301         }
302 
303         // create new ZIP file system which allows creating files
304         URI uri = URI.create("jar:" + contextFile.toURI());
305         Map<String, String> env = new HashMap<>();
306         env.put("create", "true");
307         try (FileSystem zip = FileSystems.newFileSystem(uri, env)) {
308             // copy files into the zip file, will not overwrite existing files
309             for (String filename : filesToAdd) {
310                 Path path = Paths.get(filename);
311                 if (!path.isAbsolute()) {
312                     path = mRootDir.toPath().resolve(path);
313                 }
314                 if (!path.toFile().exists()) {
315                     CLog.w("File %s not found", path);
316                     continue;
317                 }
318                 Path zipPath = zip.getPath(path.getFileName().toString());
319                 Files.copy(path, zipPath);
320             }
321         } catch (IOException | RuntimeException e) {
322             CLog.w("Failed to append files to context");
323             CLog.e(e);
324         }
325     }
326 
327     @Override
invocationEnded(long elapsedTime)328     public void invocationEnded(long elapsedTime) {
329         // Key is the file to be uploaded. Value is the destination path to upload url.
330         // For example, to upload a.txt to uploadUrl/path1/, the destination path is "path1";
331         // To upload a.txt to uploadUrl/, the destination path is null.
332         final Map<File, String> outputFiles = new HashMap<>();
333         File contextFile = null;
334         Map<String, String> envVars = new TreeMap<>();
335 
336         // Get a list of log files to upload (skip host_log_*.txt to prevent duplicate upload)
337         findFilesRecursively(mLogDir, "^((?!host_log_\\d+).)*$")
338                 .forEach(file -> outputFiles.put(file, TOOL_LOG_PATH));
339 
340         // Collect output files to upload
341         if (0 < mOutputFilePatterns.size()) {
342             final String regex =
343                     mOutputFilePatterns
344                             .stream()
345                             .map((s) -> "(" + s + ")")
346                             .collect(Collectors.joining("|"));
347             CLog.i("Collecting output files matching regex: " + regex);
348             findFilesRecursively(mRootDir, regex)
349                     .forEach(
350                             file ->
351                                     outputFiles.put(
352                                             file,
353                                             getRelativePath(Path.of(file.getPath()).getParent())
354                                                     .toString()
355                                                     .replaceAll(mOutputFileFormatPattern, "")));
356         }
357 
358         // Collect a context file if exists.
359         if (mContextFilePattern != null) {
360             CLog.i("Collecting context file matching regex: " + mContextFilePattern);
361             contextFile =
362                     findTestContextFile(
363                             mRootDir, mContextFilePattern, mFilePickingStrategy, envVars);
364             if (contextFile != null) {
365                 CLog.i("Context file = %s", contextFile.getAbsolutePath());
366                 outputFiles.put(contextFile, null);
367                 appendFilesToContext(contextFile, mExtraContextFiles);
368             } else {
369                 CLog.i("No context file found");
370             }
371         }
372 
373         final Map<File, String> outputFileUrls = uploadFiles(outputFiles, mFilePickingStrategy);
374         if (contextFile != null && outputFileUrls.containsKey(contextFile)) {
375             final IClusterClient client = getClusterClient();
376             final TestContext testContext = new TestContext();
377             testContext.setCommandLine(mRetryCommandLine);
378             testContext.addEnvVars(envVars);
379             final String name = getRelativePath(contextFile.toPath()).toString();
380             testContext.addTestResource(new TestResource(name, outputFileUrls.get(contextFile)));
381             try {
382                 CLog.i("Updating test context: %s", testContext.toString());
383                 client.updateTestContext(mRequestId, mCommandId, testContext);
384             } catch (IOException | JSONException e) {
385                 throw new RuntimeException("failed to update test context", e);
386             }
387         }
388     }
389 
390     @VisibleForTesting
getTestOutputUploader()391     TestOutputUploader getTestOutputUploader() {
392         return new TestOutputUploader();
393     }
394 
395     /** Get the {@link IClusterClient} instance used to interact with the TFC backend. */
396     @VisibleForTesting
getClusterClient()397     IClusterClient getClusterClient() {
398         if (mClusterClient == null) {
399             mClusterClient =
400                     (IClusterClient)
401                             GlobalConfiguration.getInstance()
402                                     .getConfigurationObject(IClusterClient.TYPE_NAME);
403             if (mClusterClient == null) {
404                 throw new IllegalStateException("cluster_client not defined in TF global config.");
405             }
406         }
407         return mClusterClient;
408     }
409 
410     @Override
saveLogData(String dataName, LogDataType dataType, InputStream dataStream)411     public LogFile saveLogData(String dataName, LogDataType dataType, InputStream dataStream)
412             throws IOException {
413         File log = mLogFileSaver.saveLogData(dataName, dataType, dataStream);
414         return new LogFile(log.getAbsolutePath(), null, dataType);
415     }
416 
417     @Override
getLogReportDir()418     public LogFile getLogReportDir() {
419         return new LogFile(mLogDir.getAbsolutePath(), null, LogDataType.DIR);
420     }
421 
422     @VisibleForTesting
getAttemptId()423     String getAttemptId() {
424         return mAttemptId;
425     }
426 
427     @VisibleForTesting
getOutputFileUploadUrl()428     String getOutputFileUploadUrl() {
429         return mOutputFileUploadUrl;
430     }
431 
432     @VisibleForTesting
getOutputFilePatterns()433     List<String> getOutputFilePatterns() {
434         return mOutputFilePatterns;
435     }
436 }
437