1 /*
2  * Copyright (C) 2018 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 
17 package com.android.build.tests;
18 
19 import com.android.ddmlib.Log;
20 import com.android.tradefed.build.IBuildInfo;
21 import com.android.tradefed.config.Option;
22 import com.android.tradefed.config.OptionClass;
23 import com.android.tradefed.device.DeviceNotAvailableException;
24 import com.android.tradefed.log.LogUtil.CLog;
25 import com.android.tradefed.metrics.proto.MetricMeasurement.Metric;
26 import com.android.tradefed.result.ITestInvocationListener;
27 import com.android.tradefed.result.TestDescription;
28 import com.android.tradefed.testtype.IBuildReceiver;
29 import com.android.tradefed.testtype.IRemoteTest;
30 import com.android.tradefed.util.FileUtil;
31 import com.android.tradefed.util.proto.TfMetricProtoUtil;
32 
33 import org.json.JSONArray;
34 import org.json.JSONException;
35 import org.json.JSONObject;
36 
37 import java.io.File;
38 import java.io.IOException;
39 import java.util.HashMap;
40 import java.util.HashSet;
41 import java.util.Map;
42 import java.util.Map.Entry;
43 import java.util.Set;
44 import java.util.regex.Matcher;
45 import java.util.regex.Pattern;
46 
47 /**
48  * A device-less test that parses standard Android build image stats file and performs aggregation
49  */
50 @OptionClass(alias = "image-stats")
51 public class ImageStats implements IRemoteTest, IBuildReceiver {
52 
53     // built-in aggregation labels
54     private static final String LABEL_TOTAL = "total";
55     private static final String LABEL_CATEGORIZED = "categorized";
56     private static final String LABEL_UNCATEGORIZED = "uncategorized";
57 
58     private static final String FILE_SIZES = "fileSizes";
59 
60     @Option(
61             name = "size-stats-file",
62             description =
63                     "Specify the name of the file containing image "
64                             + "stats; when \"file-from-build-info\" is set to true, the name refers to a file that "
65                             + "can be found in build info (note that build provider must be properly configured to "
66                             + "download it), otherwise it refers to a local file, typically used for debugging "
67                             + "purposes",
68             mandatory = true)
69     private Set<String> mStatsFileNames = new HashSet<>();
70 
71     @Option(
72             name = "file-from-build-info",
73             description =
74                     "If the \"size-stats-file\" references a "
75                             + "file from build info, or local; use local file for debugging purposes.")
76     private boolean mFileFromBuildInfo = false;
77 
78     @Option(
79             name = "aggregation-pattern",
80             description =
81                     "A key value pair consists of a regex as "
82                             + "key and a string label as value. The regex is used to scan and group file size "
83                             + "entries together for aggregation; that is, all files with names matching the "
84                             + "pattern are grouped together for summing; this also means that a file could be "
85                             + "counted multiple times; note that the regex must be a full match, not substring. "
86                             + "The string label is used for identifying the summed group of file sizes when "
87                             + "reporting; the regex may contain unnamed capturing groups, and values may contain "
88                             + "numerical \"back references\" as place holders to be replaced with content of "
89                             + "corresponding capturing group, example: ^.+\\.(.+)$ -> group-by-extension-\\1; back "
90                             + "references are 1-indexed and there maybe up to 9 capturing groups; no strict checks "
91                             + "are performed to ensure that capturing groups and place holders are 1:1 mapping. "
92                             + "There are 3 built-in aggregations: total, categorized and uncategorized.")
93     private Map<String, String> mAggregationPattern = new HashMap<>();
94 
95     @Option(
96             name = "min-report-size",
97             description =
98                     "Minimum size in bytes that an aggregated "
99                             + "category needs to reach before being included in reported metrics. 0 for no limit. "
100                             + "Note that built-in categories are always reported.")
101     private long mMinReportSize = 0;
102 
103     private IBuildInfo mBuildInfo;
104 
105     @Override
setBuild(IBuildInfo buildInfo)106     public void setBuild(IBuildInfo buildInfo) {
107         mBuildInfo = buildInfo;
108     }
109 
110     /** {@inheritDoc} */
111     @Override
run(ITestInvocationListener listener)112     public void run(ITestInvocationListener listener) throws DeviceNotAvailableException {
113         File statsFile;
114         // fixed run name, 1 test to run
115         long start = System.currentTimeMillis();
116         listener.testRunStarted("image-stats-run", 1);
117         for (String statsFileName : mStatsFileNames) {
118             if (mFileFromBuildInfo) {
119                 statsFile = mBuildInfo.getFile(statsFileName);
120             } else {
121                 statsFile = new File(statsFileName);
122             }
123             Map<String, String> finalFileSizeMetrics = new HashMap<>();
124             if (statsFile == null || !statsFile.exists()) {
125                 throw new RuntimeException(
126                         "Invalid image stats file (<null>) specified or it does not exist.");
127             }
128             // Use stats file name to uniquely identify the test and post only the metrics
129             // collected from that file under the test.
130             TestDescription td = new TestDescription(FileUtil.getBaseName(statsFileName),
131                     FILE_SIZES);
132             listener.testStarted(td);
133             try {
134                 parseFinalMetrics(statsFile, finalFileSizeMetrics);
135             } catch (IOException ioe) {
136                 String message = String.format(
137                         "Failed to parse image stats file: %s",
138                         statsFile.getAbsolutePath());
139                 CLog.e(message);
140                 CLog.e(ioe);
141                 listener.testFailed(td, ioe.toString());
142                 listener.testEnded(td, new HashMap<String, Metric>());
143                 listener.testRunFailed(message);
144                 listener.testRunEnded(
145                         System.currentTimeMillis() - start, new HashMap<String, Metric>());
146                 throw new RuntimeException(message, ioe);
147             }
148             String logOutput = String.format("File sizes: %s", finalFileSizeMetrics.toString());
149             if (mFileFromBuildInfo) {
150                 CLog.v(logOutput);
151             } else {
152                 // assume local debug, print outloud
153                 CLog.logAndDisplay(Log.LogLevel.VERBOSE, logOutput);
154             }
155             listener.testEnded(td, TfMetricProtoUtil.upgradeConvert(finalFileSizeMetrics));
156 
157         }
158         listener.testRunEnded(System.currentTimeMillis() - start, new HashMap<String, Metric>());
159     }
160 
161     /**
162      * Parse the aggregated metrics that matches the patterns and all individual file size metrics.
163      *
164      * @param statsFile {@link File} which contains the file name and the corresponding size
165      *  details.
166      * @param finalFileSizeMetrics final map that will have all the metrics of aggregated and
167      *     individual file names and their corresponding values.
168      * @throws IOException
169      */
parseFinalMetrics(File statsFile, Map<String, String> finalFileSizeMetrics)170     protected void parseFinalMetrics(File statsFile,
171             Map<String, String> finalFileSizeMetrics) throws IOException {
172         Map<String, Long> individualFileSizes = parseFileSizes(statsFile);
173         // Add aggregated metrics.
174         finalFileSizeMetrics.putAll(performAggregation(individualFileSizes,
175                 processAggregationPatterns(mAggregationPattern)));
176         // Add individual file size metrics.
177         finalFileSizeMetrics.putAll(convertMestricsToString(individualFileSizes));
178     }
179 
180     /**
181      * Processes json files like 'installed-files.json' (as built by standard Android build rules
182      * for device targets) into a map of file path to file sizes
183      *
184      * @param statsFile {@link File} which contains the file name and the corresponding size
185      *  details.
186      * @return
187      */
parseFileSizes(File statsFile)188     protected Map<String, Long> parseFileSizes(File statsFile) throws IOException {
189         Map<String, Long> ret = new HashMap<>();
190         try {
191             JSONArray jsonArray = new JSONArray(FileUtil.readStringFromFile(statsFile));
192             for (int i = 0; i < jsonArray.length(); i++) {
193                 JSONObject jsonObject = jsonArray.getJSONObject(i);
194                 String fileName = jsonObject.getString("Name");
195                 Long fileSize = Long.parseLong(jsonObject.getString("Size"));
196                 ret.put(fileName, fileSize);
197             }
198         } catch (JSONException e) {
199             CLog.w("JSONException: %s", e.getMessage());
200         }
201         return ret;
202     }
203 
204     /** Compiles the supplied aggregation regex's */
processAggregationPatterns(Map<String, String> rawPatterns)205     protected Map<Pattern, String> processAggregationPatterns(Map<String, String> rawPatterns) {
206         Map<Pattern, String> ret = new HashMap<>();
207         for (Map.Entry<String, String> e : rawPatterns.entrySet()) {
208             Pattern p = Pattern.compile(e.getKey());
209             ret.put(p, e.getValue());
210         }
211         return ret;
212     }
213 
214     /**
215      * Converts a matched file entry to the final aggregation label name.
216      *
217      * <p>The main thing being converted here is that capturing groups in the regex (used to match
218      * the filenames) are extracted, and used to replace the corresponding placeholders in raw
219      * label. For each 1-indexed capturing group, the captured content is used to replace the "\x"
220      * placeholders in raw label, with x being a number between 1-9, corresponding to the index of
221      * the capturing group.
222      *
223      * @param matcher the {@link Matcher} representing the matched result from the regex and input
224      * @param rawLabel the corresponding aggregation label
225      * @return
226      */
getAggregationLabel(Matcher matcher, String rawLabel)227     protected String getAggregationLabel(Matcher matcher, String rawLabel) {
228         if (matcher.groupCount() == 0) {
229             // no capturing groups, return label as is
230             return rawLabel;
231         }
232         if (matcher.groupCount() > 9) {
233             // since we are doing replacement of back references to capturing groups manually,
234             // artificially limiting this to avoid overly complex code to handle \1 vs \10
235             // in other words, "9 capturing groups ought to be enough for anybody"
236             throw new RuntimeException("too many capturing groups");
237         }
238         String label = rawLabel;
239         for (int i = 1; i <= matcher.groupCount(); i++) {
240             String marker = String.format("\\%d", i); // e.g. "\1"
241             if (label.indexOf(marker) == -1) {
242                 CLog.w(
243                         "Capturing groups were defined in regex '%s', but corresponding "
244                                 + "back-reference placeholder '%s' not found in label '%s'",
245                         matcher.pattern(), marker, rawLabel);
246                 continue;
247             }
248             label = label.replace(marker, matcher.group(i));
249         }
250         // ensure that the resulting label is not the same as the fixed "uncategorized" label
251         if (LABEL_UNCATEGORIZED.equals(label)) {
252             throw new IllegalArgumentException(
253                     String.format(
254                             "Use of aggregation label '%s' " + "conflicts with built-in default.",
255                             LABEL_UNCATEGORIZED));
256         }
257         return label;
258     }
259 
260     /**
261      * Performs aggregation by adding raw file size entries together based on the regex's the full
262      * path names are matched. Note that this means a file entry could get aggregated multiple
263      * times. The returned map will also include a fixed entry called "uncategorized" that adds the
264      * sizes of all file entries that were never matched together.
265      *
266      * @param stats the map of raw stats: full path name -> file size
267      * @param patterns the map of aggregation patterns: a regex that could match file names -> the
268      *     name of the aggregated result category (e.g. all apks)
269      * @return
270      */
performAggregation( Map<String, Long> stats, Map<Pattern, String> patterns)271     protected Map<String, String> performAggregation(
272             Map<String, Long> stats, Map<Pattern, String> patterns) {
273         Set<String> uncategorizedFiles = new HashSet<>(stats.keySet());
274         Map<String, Long> result = new HashMap<>();
275         long total = 0;
276 
277         for (Map.Entry<String, Long> stat : stats.entrySet()) {
278             // aggregate for total first
279             total += stat.getValue();
280             for (Map.Entry<Pattern, String> pattern : patterns.entrySet()) {
281                 Matcher m = pattern.getKey().matcher(stat.getKey());
282                 if (m.matches()) {
283                     // the file entry being looked at matches one of the preconfigured rules
284                     String label = getAggregationLabel(m, pattern.getValue());
285                     Long size = result.get(label);
286                     if (size == null) {
287                         size = 0L;
288                     }
289                     size += stat.getValue();
290                     result.put(label, size);
291                     // keep track of files that we've already aggregated at least once
292                     if (uncategorizedFiles.contains(stat.getKey())) {
293                         uncategorizedFiles.remove(stat.getKey());
294                     }
295                 }
296             }
297         }
298         // final pass for uncategorized files
299         long uncategorized = 0;
300         for (String file : uncategorizedFiles) {
301             uncategorized += stats.get(file);
302         }
303         Map<String, String> ret = new HashMap<>();
304         for (Map.Entry<String, Long> e : result.entrySet()) {
305             if (mMinReportSize > 0 && e.getValue() < mMinReportSize) {
306                 // has a min report size requirement and current category does not meet it
307                 CLog.v(
308                         "Skipped reporting for %s (value %d): it's below threshold %d",
309                         e.getKey(), e.getValue(), mMinReportSize);
310                 continue;
311             }
312             ret.put(e.getKey(), Long.toString(e.getValue()));
313         }
314         ret.put(LABEL_UNCATEGORIZED, Long.toString(uncategorized));
315         ret.put(LABEL_TOTAL, Long.toString(total));
316         ret.put(LABEL_CATEGORIZED, Long.toString(total - uncategorized));
317         return ret;
318     }
319 
320     /**
321      * Convert the metric type to String which is compatible for posting the results.
322      *
323      * @param allIndividualFileSizeMetrics
324      * @return
325      */
convertMestricsToString( Map<String, Long> allIndividualFileSizeMetrics)326     private Map<String, String> convertMestricsToString(
327             Map<String, Long> allIndividualFileSizeMetrics) {
328         Map<String, String> compatibleMetrics = new HashMap<>();
329         for (Entry<String, Long> fileSizeEntry : allIndividualFileSizeMetrics.entrySet()) {
330             compatibleMetrics.put(fileSizeEntry.getKey(), String.valueOf(fileSizeEntry.getValue()));
331         }
332         return compatibleMetrics;
333     }
334 }
335