blob: 6f59f5ca250f8e2bef2f16ff43e888519ffb288e [file] [log] [blame]
/*
* Copyright (C) 2018 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.build.tests;
import com.android.ddmlib.Log;
import com.android.tradefed.build.IBuildInfo;
import com.android.tradefed.config.Option;
import com.android.tradefed.config.OptionClass;
import com.android.tradefed.device.DeviceNotAvailableException;
import com.android.tradefed.log.LogUtil.CLog;
import com.android.tradefed.metrics.proto.MetricMeasurement.Metric;
import com.android.tradefed.result.ITestInvocationListener;
import com.android.tradefed.result.TestDescription;
import com.android.tradefed.testtype.IBuildReceiver;
import com.android.tradefed.testtype.IRemoteTest;
import com.android.tradefed.util.FileUtil;
import com.android.tradefed.util.proto.TfMetricProtoUtil;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* A device-less test that parses standard Android build image stats file and performs aggregation
*/
@OptionClass(alias = "image-stats")
public class ImageStats implements IRemoteTest, IBuildReceiver {
// built-in aggregation labels
private static final String LABEL_TOTAL = "total";
private static final String LABEL_CATEGORIZED = "categorized";
private static final String LABEL_UNCATEGORIZED = "uncategorized";
private static final String FILE_SIZES = "fileSizes";
@Option(
name = "size-stats-file",
description =
"Specify the name of the file containing image "
+ "stats; when \"file-from-build-info\" is set to true, the name refers to a file that "
+ "can be found in build info (note that build provider must be properly configured to "
+ "download it), otherwise it refers to a local file, typically used for debugging "
+ "purposes",
mandatory = true)
private Set<String> mStatsFileNames = new HashSet<>();
@Option(
name = "file-from-build-info",
description =
"If the \"size-stats-file\" references a "
+ "file from build info, or local; use local file for debugging purposes.")
private boolean mFileFromBuildInfo = false;
@Option(
name = "aggregation-pattern",
description =
"A key value pair consists of a regex as "
+ "key and a string label as value. The regex is used to scan and group file size "
+ "entries together for aggregation; that is, all files with names matching the "
+ "pattern are grouped together for summing; this also means that a file could be "
+ "counted multiple times; note that the regex must be a full match, not substring. "
+ "The string label is used for identifying the summed group of file sizes when "
+ "reporting; the regex may contain unnamed capturing groups, and values may contain "
+ "numerical \"back references\" as place holders to be replaced with content of "
+ "corresponding capturing group, example: ^.+\\.(.+)$ -> group-by-extension-\\1; back "
+ "references are 1-indexed and there maybe up to 9 capturing groups; no strict checks "
+ "are performed to ensure that capturing groups and place holders are 1:1 mapping. "
+ "There are 3 built-in aggregations: total, categorized and uncategorized.")
private Map<String, String> mAggregationPattern = new HashMap<>();
@Option(
name = "min-report-size",
description =
"Minimum size in bytes that an aggregated "
+ "category needs to reach before being included in reported metrics. 0 for no limit. "
+ "Note that built-in categories are always reported.")
private long mMinReportSize = 0;
private IBuildInfo mBuildInfo;
@Override
public void setBuild(IBuildInfo buildInfo) {
mBuildInfo = buildInfo;
}
/** {@inheritDoc} */
@Override
public void run(ITestInvocationListener listener) throws DeviceNotAvailableException {
File statsFile;
// fixed run name, 1 test to run
long start = System.currentTimeMillis();
listener.testRunStarted("image-stats-run", 1);
for (String statsFileName : mStatsFileNames) {
if (mFileFromBuildInfo) {
statsFile = mBuildInfo.getFile(statsFileName);
} else {
statsFile = new File(statsFileName);
}
Map<String, String> finalFileSizeMetrics = new HashMap<>();
if (statsFile == null || !statsFile.exists()) {
throw new RuntimeException(
"Invalid image stats file (<null>) specified or it does not exist.");
}
// Use stats file name to uniquely identify the test and post only the metrics
// collected from that file under the test.
TestDescription td = new TestDescription(FileUtil.getBaseName(statsFileName),
FILE_SIZES);
listener.testStarted(td);
try (InputStream in = new FileInputStream(statsFile)) {
parseFinalMetrics(in, finalFileSizeMetrics);
} catch (IOException ioe) {
String message = String.format(
"Failed to parse image stats file: %s",
statsFile.getAbsolutePath());
CLog.e(message);
CLog.e(ioe);
listener.testFailed(td, ioe.toString());
listener.testEnded(td, new HashMap<String, Metric>());
listener.testRunFailed(message);
listener.testRunEnded(
System.currentTimeMillis() - start, new HashMap<String, Metric>());
throw new RuntimeException(message, ioe);
}
String logOutput = String.format("File sizes: %s", finalFileSizeMetrics.toString());
if (mFileFromBuildInfo) {
CLog.v(logOutput);
} else {
// assume local debug, print outloud
CLog.logAndDisplay(Log.LogLevel.VERBOSE, logOutput);
}
listener.testEnded(td, TfMetricProtoUtil.upgradeConvert(finalFileSizeMetrics));
}
listener.testRunEnded(System.currentTimeMillis() - start, new HashMap<String, Metric>());
}
/**
* Parse the aggregated metrics that matches the patterns and all individual file size metrics.
*
* @param in an unread {@link InputStream} for the content of the file sizes; the stream will be
* fully read after executing the method
* @param finalFileSizeMetrics final map that will have all the metrics of aggregated and
* individual file names and their corresponding values.
* @throws IOException
*/
protected void parseFinalMetrics(InputStream in,
Map<String, String> finalFileSizeMetrics) throws IOException {
Map<String, Long> individualFileSizes = parseFileSizes(in);
// Add aggregated metrics.
finalFileSizeMetrics.putAll(performAggregation(individualFileSizes,
processAggregationPatterns(mAggregationPattern)));
// Add individual file size metrics.
finalFileSizeMetrics.putAll(convertMestricsToString(individualFileSizes));
}
/**
* Processes text files like 'installed-files.txt' (as built by standard Android build rules for
* device targets) into a map of file path to file sizes
*
* @param in an unread {@link InputStream} for the content of the file sizes; the stream will be
* fully read after executing the method
* @return
*/
protected Map<String, Long> parseFileSizes(InputStream in) throws IOException {
Map<String, Long> ret = new HashMap<>();
try (BufferedReader br = new BufferedReader(new InputStreamReader(in))) {
String line;
while ((line = br.readLine()) != null) {
// trim both ends of the raw line and make a split by whitespaces
// e.g. trying to match a line like this:
// 96992106 /system/app/Chrome/Chrome.apk
String[] fields = line.trim().split("\\s+");
if (fields.length != 2) {
CLog.w("Unable to split line to file size and name: %s", line);
continue;
}
long size = 0;
try {
size = Long.parseLong(fields[0]);
} catch (NumberFormatException nfe) {
CLog.w("Failed to parse file size from field '%s', ignored", fields[0]);
continue;
}
ret.put(fields[1], size);
}
}
return ret;
}
/** Compiles the supplied aggregation regex's */
protected Map<Pattern, String> processAggregationPatterns(Map<String, String> rawPatterns) {
Map<Pattern, String> ret = new HashMap<>();
for (Map.Entry<String, String> e : rawPatterns.entrySet()) {
Pattern p = Pattern.compile(e.getKey());
ret.put(p, e.getValue());
}
return ret;
}
/**
* Converts a matched file entry to the final aggregation label name.
*
* <p>The main thing being converted here is that capturing groups in the regex (used to match
* the filenames) are extracted, and used to replace the corresponding placeholders in raw
* label. For each 1-indexed capturing group, the captured content is used to replace the "\x"
* placeholders in raw label, with x being a number between 1-9, corresponding to the index of
* the capturing group.
*
* @param matcher the {@link Matcher} representing the matched result from the regex and input
* @param rawLabel the corresponding aggregation label
* @return
*/
protected String getAggregationLabel(Matcher matcher, String rawLabel) {
if (matcher.groupCount() == 0) {
// no capturing groups, return label as is
return rawLabel;
}
if (matcher.groupCount() > 9) {
// since we are doing replacement of back references to capturing groups manually,
// artificially limiting this to avoid overly complex code to handle \1 vs \10
// in other words, "9 capturing groups ought to be enough for anybody"
throw new RuntimeException("too many capturing groups");
}
String label = rawLabel;
for (int i = 1; i <= matcher.groupCount(); i++) {
String marker = String.format("\\%d", i); // e.g. "\1"
if (label.indexOf(marker) == -1) {
CLog.w(
"Capturing groups were defined in regex '%s', but corresponding "
+ "back-reference placeholder '%s' not found in label '%s'",
matcher.pattern(), marker, rawLabel);
continue;
}
label = label.replace(marker, matcher.group(i));
}
// ensure that the resulting label is not the same as the fixed "uncategorized" label
if (LABEL_UNCATEGORIZED.equals(label)) {
throw new IllegalArgumentException(
String.format(
"Use of aggregation label '%s' " + "conflicts with built-in default.",
LABEL_UNCATEGORIZED));
}
return label;
}
/**
* Performs aggregation by adding raw file size entries together based on the regex's the full
* path names are matched. Note that this means a file entry could get aggregated multiple
* times. The returned map will also include a fixed entry called "uncategorized" that adds the
* sizes of all file entries that were never matched together.
*
* @param stats the map of raw stats: full path name -> file size
* @param patterns the map of aggregation patterns: a regex that could match file names -> the
* name of the aggregated result category (e.g. all apks)
* @return
*/
protected Map<String, String> performAggregation(
Map<String, Long> stats, Map<Pattern, String> patterns) {
Set<String> uncategorizedFiles = new HashSet<>(stats.keySet());
Map<String, Long> result = new HashMap<>();
long total = 0;
for (Map.Entry<String, Long> stat : stats.entrySet()) {
// aggregate for total first
total += stat.getValue();
for (Map.Entry<Pattern, String> pattern : patterns.entrySet()) {
Matcher m = pattern.getKey().matcher(stat.getKey());
if (m.matches()) {
// the file entry being looked at matches one of the preconfigured rules
String label = getAggregationLabel(m, pattern.getValue());
Long size = result.get(label);
if (size == null) {
size = 0L;
}
size += stat.getValue();
result.put(label, size);
// keep track of files that we've already aggregated at least once
if (uncategorizedFiles.contains(stat.getKey())) {
uncategorizedFiles.remove(stat.getKey());
}
}
}
}
// final pass for uncategorized files
long uncategorized = 0;
for (String file : uncategorizedFiles) {
uncategorized += stats.get(file);
}
Map<String, String> ret = new HashMap<>();
for (Map.Entry<String, Long> e : result.entrySet()) {
if (mMinReportSize > 0 && e.getValue() < mMinReportSize) {
// has a min report size requirement and current category does not meet it
CLog.v(
"Skipped reporting for %s (value %d): it's below threshold %d",
e.getKey(), e.getValue(), mMinReportSize);
continue;
}
ret.put(e.getKey(), Long.toString(e.getValue()));
}
ret.put(LABEL_UNCATEGORIZED, Long.toString(uncategorized));
ret.put(LABEL_TOTAL, Long.toString(total));
ret.put(LABEL_CATEGORIZED, Long.toString(total - uncategorized));
return ret;
}
/**
* Convert the metric type to String which is compatible for posting the results.
*
* @param allIndividualFileSizeMetrics
* @return
*/
private Map<String, String> convertMestricsToString(
Map<String, Long> allIndividualFileSizeMetrics) {
Map<String, String> compatibleMetrics = new HashMap<>();
for (Entry<String, Long> fileSizeEntry : allIndividualFileSizeMetrics.entrySet()) {
compatibleMetrics.put(fileSizeEntry.getKey(), String.valueOf(fileSizeEntry.getValue()));
}
return compatibleMetrics;
}
}