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