| /* |
| * Copyright (C) 2019 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| package com.android.tradefed.cluster; |
| |
| import com.android.annotations.VisibleForTesting; |
| import com.android.tradefed.config.GlobalConfiguration; |
| import com.android.tradefed.config.Option; |
| import com.android.tradefed.config.OptionClass; |
| import com.android.tradefed.invoker.IInvocationContext; |
| import com.android.tradefed.log.LogUtil.CLog; |
| import com.android.tradefed.result.ILogSaver; |
| import com.android.tradefed.result.LogDataType; |
| import com.android.tradefed.result.LogFile; |
| import com.android.tradefed.result.LogFileSaver; |
| import com.android.tradefed.util.FileUtil; |
| |
| import org.json.JSONException; |
| |
| import java.io.File; |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.net.MalformedURLException; |
| import java.net.URI; |
| import java.nio.file.FileSystem; |
| import java.nio.file.FileSystems; |
| import java.nio.file.FileVisitOption; |
| import java.nio.file.Files; |
| import java.nio.file.Path; |
| import java.nio.file.Paths; |
| import java.util.ArrayList; |
| import java.util.Comparator; |
| import java.util.HashMap; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Optional; |
| import java.util.Set; |
| import java.util.TreeMap; |
| import java.util.TreeSet; |
| import java.util.regex.Matcher; |
| import java.util.regex.Pattern; |
| import java.util.stream.Collectors; |
| import java.util.stream.Stream; |
| |
| /** A {@link ILogSaver} class to upload test outputs to TFC. */ |
| @OptionClass(alias = "cluster", global_namespace = false) |
| public class ClusterLogSaver implements ILogSaver { |
| |
| /** A name of a text file containing all test output file names. */ |
| public static final String FILE_NAMES_FILE_NAME = "FILES"; |
| |
| /** A name of a subdirectory containing all files generated by host process. */ |
| public static final String TOOL_LOG_PATH = "tool-logs"; |
| |
| /** File picking strategies. */ |
| public static enum FilePickingStrategy { |
| PICK_LAST, |
| PICK_FIRST |
| } |
| |
| @Option(name = "root-dir", description = "A root directory", mandatory = true) |
| private File mRootDir; |
| |
| @Option(name = "request-id", description = "A request ID", mandatory = true) |
| private String mRequestId; |
| |
| @Option(name = "command-id", description = "A command ID", mandatory = true) |
| private String mCommandId; |
| |
| @Option(name = "attempt-id", description = "A command attempt ID", mandatory = true) |
| private String mAttemptId; |
| |
| @Option( |
| name = "output-file-upload-url", |
| description = "URL to upload output files to", |
| mandatory = true) |
| private String mOutputFileUploadUrl; |
| |
| @Option(name = "output-file-pattern", description = "Output file patterns") |
| private List<String> mOutputFilePatterns = new ArrayList<>(); |
| |
| @Option( |
| name = "context-file-pattern", |
| description = |
| "A regex pattern for test context file(s). A test context file is a file to be" |
| + " used in successive invocations to pass context information.") |
| private String mContextFilePattern = null; |
| |
| @Option(name = "file-picking-strategy", description = "A picking strategy for file(s)") |
| private FilePickingStrategy mFilePickingStrategy = FilePickingStrategy.PICK_LAST; |
| |
| @Option( |
| name = "extra-context-file", |
| description = |
| "Additional files to include in the context file. " |
| + "Context file must be a ZIP archive.") |
| private List<String> mExtraContextFiles = new ArrayList<>(); |
| |
| @Option( |
| name = "retry-command-line", |
| description = |
| "A command line to store in test context. This will replace the original" |
| + " command line in a retry invocation.") |
| private String mRetryCommandLine = null; |
| |
| private File mLogDir; |
| private LogFileSaver mLogFileSaver = null; |
| private IClusterClient mClusterClient = null; |
| |
| @Override |
| public void invocationStarted(IInvocationContext context) { |
| mLogDir = new File(mRootDir, "logs"); |
| mLogFileSaver = new LogFileSaver(mLogDir); |
| } |
| |
| private Path getRelativePath(Path p) { |
| return mRootDir.toPath().relativize(p); |
| } |
| |
| /** Returns a Path stream for all files under a directory matching a given pattern. */ |
| @SuppressWarnings("StreamResourceLeak") |
| private Stream<Path> getPathStream(final File dir, final Pattern pattern) throws IOException { |
| return Files.find( |
| dir.toPath(), |
| Integer.MAX_VALUE, |
| (path, attr) -> |
| attr.isRegularFile() |
| && pattern.matcher(getRelativePath(path).toString()).matches(), |
| FileVisitOption.FOLLOW_LINKS); |
| } |
| |
| private Set<File> findFilesRecursively(final File dir, final String regex) { |
| final Pattern pattern = Pattern.compile(regex); |
| try (final Stream<Path> stream = getPathStream(dir, pattern)) { |
| return stream.map(Path::toFile).collect(Collectors.toSet()); |
| } catch (IOException e) { |
| throw new RuntimeException("Failed to collect output files", e); |
| } |
| } |
| |
| private Set<String> getGroupNames(final String regex) { |
| final Set<String> names = new TreeSet<>(); |
| Matcher m = Pattern.compile("\\(\\?<([a-zA-Z][a-zA-Z0-9]*)>").matcher(regex); |
| while (m.find()) { |
| names.add(m.group(1)); |
| } |
| return names; |
| } |
| |
| /** |
| * Find a test context file and collect environment vars if exists. |
| * |
| * <p>If there are multiple matches, it will only collect the first or last one in |
| * lexicographical order according to a given file picking strategy. If a newly collected |
| * environment var already exists in a given map, it will be overridden. |
| * |
| * @param dir a root directory. |
| * @param regex a regex pattern for a context file path. A relative path is used for matching. |
| * @param strategy a file picking strategy. |
| * @param envVars a map for environment vars. |
| * @return a {@link File} object. |
| */ |
| @VisibleForTesting |
| File findTestContextFile( |
| final File dir, |
| final String regex, |
| final FilePickingStrategy strategy, |
| final Map<String, String> envVars) { |
| final Pattern pattern = Pattern.compile(regex); |
| try (Stream<Path> stream = getPathStream(dir, pattern)) { |
| Optional<Path> op = null; |
| switch (strategy) { |
| case PICK_FIRST: |
| op = stream.sorted().findFirst(); |
| break; |
| case PICK_LAST: |
| op = stream.sorted(Comparator.reverseOrder()).findFirst(); |
| break; |
| } |
| if (op == null || !op.isPresent()) { |
| return null; |
| } |
| final Path p = op.get(); |
| Set<String> groupNames = getGroupNames(regex); |
| CLog.d("Context var names: %s", groupNames); |
| Path relPath = dir.toPath().relativize(p); |
| Matcher matcher = pattern.matcher(relPath.toString()); |
| // One needs to call matches() before calling group() method. |
| matcher.matches(); |
| for (final String name : groupNames) { |
| final String value = matcher.group(name); |
| if (value == null) { |
| continue; |
| } |
| envVars.put(name, value); |
| } |
| return p.toFile(); |
| } catch (IOException e) { |
| throw new RuntimeException("Failed to collect a context file", e); |
| } |
| } |
| |
| /** Determine a file's new path after applying an optional prefix. */ |
| private String getDestinationPath(String prefix, File file) { |
| String filename = file.getName(); |
| return prefix == null ? filename : Paths.get(prefix, filename).toString(); |
| } |
| |
| /** |
| * Create a text file containing a list of file names. |
| * |
| * @param filenames filenames to write. |
| * @param destFile a {@link File} where to write names to. |
| * @throws IOException if writing fails |
| */ |
| private void writeFilenamesToFile(Set<String> filenames, File destFile) throws IOException { |
| String content = filenames.stream().sorted().collect(Collectors.joining("\n")); |
| FileUtil.writeToFile(content, destFile); |
| } |
| |
| /** |
| * Upload files to mOutputFileUploadUrl. |
| * |
| * @param fileMap a {@link Map} of file and destination path string pairs. |
| * @return a {@link Map} of file and URL pairs. |
| */ |
| private Map<File, String> uploadFiles(Map<File, String> fileMap, FilePickingStrategy strategy) { |
| // construct a map of unique destination paths and files, to prevent duplicate uploads |
| Map<String, File> destinationMap = |
| fileMap.entrySet() |
| .stream() |
| .sorted(Comparator.comparing(Map.Entry::getKey)) // sort by filename |
| .collect( |
| Collectors.toMap( |
| e -> getDestinationPath(e.getValue(), e.getKey()), |
| Map.Entry::getKey, |
| // use strategy if two files have the same destination |
| (first, second) -> |
| strategy == FilePickingStrategy.PICK_FIRST |
| ? first |
| : second)); |
| fileMap.keySet().retainAll(destinationMap.values()); |
| CLog.i("Collected %d files to upload", fileMap.size()); |
| fileMap.keySet().forEach(f -> CLog.i(f.getAbsolutePath())); |
| |
| // Create a file names file. |
| File fileNamesFile = new File(mRootDir, FILE_NAMES_FILE_NAME); |
| try { |
| writeFilenamesToFile(destinationMap.keySet(), fileNamesFile); |
| } catch (IOException e) { |
| CLog.e("Failed to write %s", fileNamesFile.getAbsolutePath()); |
| } |
| |
| final TestOutputUploader uploader = getTestOutputUploader(); |
| try { |
| uploader.setUploadUrl(mOutputFileUploadUrl); |
| } catch (MalformedURLException e) { |
| throw new RuntimeException("Failed to set upload URL", e); |
| } |
| |
| fileMap.put(fileNamesFile, null); |
| final Map<File, String> fileUrls = new TreeMap<>(); |
| int index = 1; |
| for (Map.Entry<File, String> entry : fileMap.entrySet()) { |
| File file = entry.getKey(); |
| CLog.i("Uploading file %d of %d: %s", index, fileMap.size(), file.getAbsolutePath()); |
| try { |
| fileUrls.put(file, uploader.uploadFile(file, entry.getValue())); |
| } catch (IOException | RuntimeException e) { |
| CLog.e("Failed to upload %s: %s", file, e); |
| } |
| index++; |
| } |
| return fileUrls; |
| } |
| |
| /** If the context file is a zip file, will append the specified files to it. */ |
| @VisibleForTesting |
| void appendFilesToContext(File contextFile, List<String> filesToAdd) { |
| if (filesToAdd.isEmpty()) { |
| return; |
| } |
| |
| // create new ZIP file system which allows creating files |
| URI uri = URI.create("jar:" + contextFile.toURI()); |
| Map<String, String> env = new HashMap<>(); |
| env.put("create", "true"); |
| try (FileSystem zip = FileSystems.newFileSystem(uri, env)) { |
| // copy files into the zip file, will not overwrite existing files |
| for (String filename : filesToAdd) { |
| Path path = Paths.get(filename); |
| if (!path.isAbsolute()) { |
| path = mRootDir.toPath().resolve(path); |
| } |
| if (!path.toFile().exists()) { |
| CLog.w("File %s not found", path); |
| continue; |
| } |
| Path zipPath = zip.getPath(path.getFileName().toString()); |
| Files.copy(path, zipPath); |
| } |
| } catch (IOException | RuntimeException e) { |
| CLog.w("Failed to append files to context"); |
| CLog.e(e); |
| } |
| } |
| |
| @Override |
| public void invocationEnded(long elapsedTime) { |
| // Key is the file to be uploaded. Value is the destination path to upload url. |
| // For example, to upload a.txt to uploadUrl/path1/, the destination path is "path1"; |
| // To upload a.txt to uploadUrl/, the destination path is null. |
| final Map<File, String> outputFiles = new HashMap<>(); |
| File contextFile = null; |
| Map<String, String> envVars = new TreeMap<>(); |
| |
| // Get a list of log files to upload (skip host_log_*.txt to prevent duplicate upload) |
| findFilesRecursively(mLogDir, "^((?!host_log_\\d+).)*$") |
| .forEach(file -> outputFiles.put(file, TOOL_LOG_PATH)); |
| |
| // Collect output files to upload |
| if (0 < mOutputFilePatterns.size()) { |
| final String regex = |
| mOutputFilePatterns |
| .stream() |
| .map((s) -> "(" + s + ")") |
| .collect(Collectors.joining("|")); |
| CLog.i("Collecting output files matching regex: " + regex); |
| findFilesRecursively(mRootDir, regex).forEach(file -> outputFiles.put(file, null)); |
| } |
| |
| // Collect a context file if exists. |
| if (mContextFilePattern != null) { |
| CLog.i("Collecting context file matching regex: " + mContextFilePattern); |
| contextFile = |
| findTestContextFile( |
| mRootDir, mContextFilePattern, mFilePickingStrategy, envVars); |
| if (contextFile != null) { |
| CLog.i("Context file = %s", contextFile.getAbsolutePath()); |
| outputFiles.put(contextFile, null); |
| appendFilesToContext(contextFile, mExtraContextFiles); |
| } else { |
| CLog.i("No context file found"); |
| } |
| } |
| |
| final Map<File, String> outputFileUrls = uploadFiles(outputFiles, mFilePickingStrategy); |
| if (contextFile != null && outputFileUrls.containsKey(contextFile)) { |
| final IClusterClient client = getClusterClient(); |
| final TestContext testContext = new TestContext(); |
| testContext.setCommandLine(mRetryCommandLine); |
| testContext.addEnvVars(envVars); |
| final String name = getRelativePath(contextFile.toPath()).toString(); |
| testContext.addTestResource(new TestResource(name, outputFileUrls.get(contextFile))); |
| try { |
| CLog.i("Updating test context: %s", testContext.toString()); |
| client.updateTestContext(mRequestId, mCommandId, testContext); |
| } catch (IOException | JSONException e) { |
| throw new RuntimeException("failed to update test context", e); |
| } |
| } |
| } |
| |
| @VisibleForTesting |
| TestOutputUploader getTestOutputUploader() { |
| return new TestOutputUploader(); |
| } |
| |
| /** Get the {@link IClusterClient} instance used to interact with the TFC backend. */ |
| @VisibleForTesting |
| IClusterClient getClusterClient() { |
| if (mClusterClient == null) { |
| mClusterClient = |
| (IClusterClient) |
| GlobalConfiguration.getInstance() |
| .getConfigurationObject(IClusterClient.TYPE_NAME); |
| if (mClusterClient == null) { |
| throw new IllegalStateException("cluster_client not defined in TF global config."); |
| } |
| } |
| return mClusterClient; |
| } |
| |
| @Override |
| public LogFile saveLogData(String dataName, LogDataType dataType, InputStream dataStream) |
| throws IOException { |
| File log = mLogFileSaver.saveLogData(dataName, dataType, dataStream); |
| return new LogFile(log.getAbsolutePath(), null, dataType); |
| } |
| |
| @Override |
| public LogFile saveLogDataRaw(String dataName, LogDataType dataType, InputStream dataStream) |
| throws IOException { |
| File log = mLogFileSaver.saveLogDataRaw(dataName, dataType.getFileExt(), dataStream); |
| return new LogFile(log.getAbsolutePath(), null, dataType); |
| } |
| |
| @Override |
| public LogFile getLogReportDir() { |
| return new LogFile(mLogDir.getAbsolutePath(), null, LogDataType.DIR); |
| } |
| |
| @VisibleForTesting |
| String getAttemptId() { |
| return mAttemptId; |
| } |
| |
| @VisibleForTesting |
| String getOutputFileUploadUrl() { |
| return mOutputFileUploadUrl; |
| } |
| |
| @VisibleForTesting |
| List<String> getOutputFilePatterns() { |
| return mOutputFilePatterns; |
| } |
| } |