/* * Copyright (c) 2016 Google Inc. All Rights Reserved. * * 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.vts.job; import com.android.vts.entity.DeviceInfoEntity; import com.android.vts.entity.TestAcknowledgmentEntity; import com.android.vts.entity.TestCaseRunEntity; import com.android.vts.entity.TestCaseRunEntity.TestCase; import com.android.vts.entity.TestRunEntity; import com.android.vts.entity.TestStatusEntity; import com.android.vts.entity.TestStatusEntity.TestCaseReference; import com.android.vts.proto.VtsReportMessage.TestCaseResult; import com.android.vts.util.DatastoreHelper; import com.android.vts.util.EmailHelper; import com.android.vts.util.FilterUtil; import com.android.vts.util.TimeUtil; import com.google.appengine.api.datastore.DatastoreFailureException; import com.google.appengine.api.datastore.DatastoreService; import com.google.appengine.api.datastore.DatastoreServiceFactory; import com.google.appengine.api.datastore.DatastoreTimeoutException; import com.google.appengine.api.datastore.Entity; import com.google.appengine.api.datastore.EntityNotFoundException; import com.google.appengine.api.datastore.FetchOptions; import com.google.appengine.api.datastore.Key; import com.google.appengine.api.datastore.KeyFactory; import com.google.appengine.api.datastore.Query; import com.google.appengine.api.datastore.Query.Filter; import com.google.appengine.api.datastore.Query.SortDirection; import com.google.appengine.api.datastore.Transaction; import com.google.appengine.api.taskqueue.Queue; import com.google.appengine.api.taskqueue.QueueFactory; import com.google.appengine.api.taskqueue.TaskOptions; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.util.ArrayList; import java.util.Comparator; import java.util.ConcurrentModificationException; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; import javax.mail.Message; import javax.mail.MessagingException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.apache.commons.lang.StringUtils; /** Represents the notifications service which is automatically called on a fixed schedule. */ public class VtsAlertJobServlet extends BaseJobServlet { private static final String ALERT_JOB_URL = "/task/vts_alert_job"; protected static final Logger logger = Logger.getLogger(VtsAlertJobServlet.class.getName()); protected static final int MAX_RUN_COUNT = 1000; // maximum number of runs to query for /** * Process the current test case failures for a test. * * @param status The TestStatusEntity object for the test. * @returns a map from test case name to the test case run ID for which the test case failed. */ private static Map getCurrentFailures(TestStatusEntity status) { if (status.getFailingTestCases() == null || status.getFailingTestCases().size() == 0) { return new HashMap<>(); } DatastoreService datastore = DatastoreServiceFactory.getDatastoreService(); Map failingTestcases = new HashMap<>(); Set gets = new HashSet<>(); for (TestCaseReference testCaseRef : status.getFailingTestCases()) { gets.add(KeyFactory.createKey(TestCaseRunEntity.KIND, testCaseRef.parentId)); } if (gets.size() == 0) { return failingTestcases; } Map testCaseMap = datastore.get(gets); for (TestCaseReference testCaseRef : status.getFailingTestCases()) { Key key = KeyFactory.createKey(TestCaseRunEntity.KIND, testCaseRef.parentId); if (!testCaseMap.containsKey(key)) { continue; } Entity testCaseRun = testCaseMap.get(key); TestCaseRunEntity testCaseRunEntity = TestCaseRunEntity.fromEntity(testCaseRun); if (testCaseRunEntity.testCases.size() <= testCaseRef.offset) { continue; } TestCase testCase = testCaseRunEntity.testCases.get(testCaseRef.offset); failingTestcases.put(testCase.name, testCase); } return failingTestcases; } /** * Get the test acknowledgments for a test key. * * @param testKey The key to the test whose acknowledgments to fetch. * @return A list of test acknowledgments. */ private static List getTestCaseAcknowledgments(Key testKey) { DatastoreService datastore = DatastoreServiceFactory.getDatastoreService(); List acks = new ArrayList<>(); Filter testFilter = new Query.FilterPredicate( TestAcknowledgmentEntity.TEST_KEY, Query.FilterOperator.EQUAL, testKey); Query q = new Query(TestAcknowledgmentEntity.KIND).setFilter(testFilter); for (Entity ackEntity : datastore.prepare(q).asIterable()) { TestAcknowledgmentEntity ack = TestAcknowledgmentEntity.fromEntity(ackEntity); if (ack == null) continue; acks.add(ack); } return acks; } /** * Get the test runs for the test in the specified time window. * *

If the start and end time delta is greater than one day, the query will be truncated. * * @param testKey The key to the test whose runs to query. * @param startTime The start time for the query. * @param endTime The end time for the query. * @return A list of test runs in the specified time window. */ private static List getTestRuns(Key testKey, long startTime, long endTime) { DatastoreService datastore = DatastoreServiceFactory.getDatastoreService(); Filter testTypeFilter = FilterUtil.getTestTypeFilter(false, true, false); long delta = endTime - startTime; delta = Math.min(delta, TimeUnit.DAYS.toMicros(1)); Filter runFilter = FilterUtil.getTimeFilter( testKey, TestRunEntity.KIND, endTime - delta + 1, endTime, testTypeFilter); Query q = new Query(TestRunEntity.KIND) .setAncestor(testKey) .setFilter(runFilter) .addSort(Entity.KEY_RESERVED_PROPERTY, SortDirection.DESCENDING); List testRuns = new ArrayList<>(); for (Entity testRunEntity : datastore.prepare(q).asIterable(FetchOptions.Builder.withLimit(MAX_RUN_COUNT))) { TestRunEntity testRun = TestRunEntity.fromEntity(testRunEntity); if (testRun == null) continue; testRuns.add(testRun); } return testRuns; } /** * Separate the test cases which are acknowledged by the provided acknowledgments. * * @param testCases The list of test case names. * @param devices The list of devices for a test run. * @param acks The list of acknowledgments for the test. * @return A list of acknowledged test case names that have been removed from the input test * cases. */ public static Set separateAcknowledged( Set testCases, List devices, List acks) { Set acknowledged = new HashSet<>(); for (TestAcknowledgmentEntity ack : acks) { boolean allDevices = ack.getDevices() == null || ack.getDevices().size() == 0; boolean allBranches = ack.getBranches() == null || ack.getBranches().size() == 0; boolean isRelevant = allDevices && allBranches; // Determine if the acknowledgment is relevant to the devices. if (!isRelevant) { for (DeviceInfoEntity device : devices) { boolean deviceAcknowledged = allDevices || ack.getDevices().contains(device.getBuildFlavor()); boolean branchAcknowledged = allBranches || ack.getBranches().contains(device.getBranch()); if (deviceAcknowledged && branchAcknowledged) isRelevant = true; } } if (isRelevant) { // Separate the test cases boolean allTestCases = ack.getTestCaseNames() == null || ack.getTestCaseNames().size() == 0; if (allTestCases) { acknowledged.addAll(testCases); testCases.removeAll(acknowledged); } else { for (String testCase : ack.getTestCaseNames()) { if (testCases.contains(testCase)) { acknowledged.add(testCase); testCases.remove(testCase); } } } } } return acknowledged; } /** * Checks whether any new failures have occurred beginning since (and including) startTime. * * @param testRuns The list of test runs for which to update the status. * @param link The string URL linking to the test's status table. * @param failedTestCaseMap The map of test case names to TestCase for those failing in the last * status update. * @param emailAddresses The list of email addresses to send notifications to. * @param messages The email Message queue. * @returns latest TestStatusMessage or null if no update is available. * @throws IOException */ public TestStatusEntity getTestStatus( List testRuns, String link, Map failedTestCaseMap, List testAcks, List emailAddresses, List messages) throws IOException { if (testRuns.size() == 0) return null; DatastoreService datastore = DatastoreServiceFactory.getDatastoreService(); TestRunEntity mostRecentRun = null; Map mostRecentTestCaseResults = new HashMap<>(); Map testCaseBreakageMap = new HashMap<>(); int passingTestcaseCount = 0; List failingTestCases = new ArrayList<>(); Set fixedTestcases = new HashSet<>(); Set newTestcaseFailures = new HashSet<>(); Set continuedTestcaseFailures = new HashSet<>(); Set skippedTestcaseFailures = new HashSet<>(); Set transientTestcaseFailures = new HashSet<>(); for (TestRunEntity testRun : testRuns) { if (mostRecentRun == null) { mostRecentRun = testRun; } List testCaseKeys = new ArrayList<>(); for (long testCaseId : testRun.getTestCaseIds()) { testCaseKeys.add(KeyFactory.createKey(TestCaseRunEntity.KIND, testCaseId)); } Map entityMap = datastore.get(testCaseKeys); for (Key testCaseKey : testCaseKeys) { if (!entityMap.containsKey(testCaseKey)) { logger.log(Level.WARNING, "Test case entity missing: " + testCaseKey); continue; } Entity testCaseRun = entityMap.get(testCaseKey); TestCaseRunEntity testCaseRunEntity = TestCaseRunEntity.fromEntity(testCaseRun); if (testCaseRunEntity == null) { logger.log(Level.WARNING, "Invalid test case run: " + testCaseRun.getKey()); continue; } for (TestCase testCase : testCaseRunEntity.testCases) { String testCaseName = testCase.name; TestCaseResult result = TestCaseResult.valueOf(testCase.result); if (mostRecentRun == testRun) { mostRecentTestCaseResults.put(testCaseName, result); } else { if (!mostRecentTestCaseResults.containsKey(testCaseName)) { // Deprecate notifications for tests that are not present on newer runs continue; } TestCaseResult mostRecentRes = mostRecentTestCaseResults.get(testCaseName); if (mostRecentRes == TestCaseResult.TEST_CASE_RESULT_SKIP) { mostRecentTestCaseResults.put(testCaseName, result); } else if (mostRecentRes == TestCaseResult.TEST_CASE_RESULT_PASS) { // Test is passing now, witnessed a transient failure if (result != TestCaseResult.TEST_CASE_RESULT_PASS && result != TestCaseResult.TEST_CASE_RESULT_SKIP) { transientTestcaseFailures.add(testCaseName); } } } // Record test case breakages if (result != TestCaseResult.TEST_CASE_RESULT_PASS && result != TestCaseResult.TEST_CASE_RESULT_SKIP) { testCaseBreakageMap.put(testCaseName, testCase); } } } } Set buildIdList = new HashSet<>(); List devices = new ArrayList<>(); Query deviceQuery = new Query(DeviceInfoEntity.KIND).setAncestor(mostRecentRun.getKey()); for (Entity device : datastore.prepare(deviceQuery).asIterable()) { DeviceInfoEntity deviceEntity = DeviceInfoEntity.fromEntity(device); if (deviceEntity == null) { continue; } buildIdList.add(deviceEntity.getBuildId()); devices.add(deviceEntity); } String footer = EmailHelper.getEmailFooter(mostRecentRun, devices, link); String buildId = StringUtils.join(buildIdList, ","); for (String testCaseName : mostRecentTestCaseResults.keySet()) { TestCaseResult mostRecentResult = mostRecentTestCaseResults.get(testCaseName); boolean previouslyFailed = failedTestCaseMap.containsKey(testCaseName); if (mostRecentResult == TestCaseResult.TEST_CASE_RESULT_SKIP) { // persist previous status if (previouslyFailed) { skippedTestcaseFailures.add(testCaseName); failingTestCases.add( new TestCaseReference(failedTestCaseMap.get(testCaseName))); } else { ++passingTestcaseCount; } } else if (mostRecentResult == TestCaseResult.TEST_CASE_RESULT_PASS) { ++passingTestcaseCount; if (previouslyFailed && !transientTestcaseFailures.contains(testCaseName)) { fixedTestcases.add(testCaseName); } } else { if (!previouslyFailed) { newTestcaseFailures.add(testCaseName); failingTestCases.add( new TestCaseReference(testCaseBreakageMap.get(testCaseName))); } else { continuedTestcaseFailures.add(testCaseName); failingTestCases.add( new TestCaseReference(failedTestCaseMap.get(testCaseName))); } } } Set acknowledgedFailures = separateAcknowledged(newTestcaseFailures, devices, testAcks); acknowledgedFailures.addAll( separateAcknowledged(transientTestcaseFailures, devices, testAcks)); acknowledgedFailures.addAll( separateAcknowledged(continuedTestcaseFailures, devices, testAcks)); String summary = new String(); if (newTestcaseFailures.size() + continuedTestcaseFailures.size() > 0) { summary += "The following test cases failed in the latest test run:
"; // Add new test case failures to top of summary in bold font. List sortedNewTestcaseFailures = new ArrayList<>(newTestcaseFailures); sortedNewTestcaseFailures.sort(Comparator.naturalOrder()); for (String testcaseName : sortedNewTestcaseFailures) { summary += "- " + "" + testcaseName + "
"; } // Add continued test case failures to summary. List sortedContinuedTestcaseFailures = new ArrayList<>(continuedTestcaseFailures); sortedContinuedTestcaseFailures.sort(Comparator.naturalOrder()); for (String testcaseName : sortedContinuedTestcaseFailures) { summary += "- " + testcaseName + "
"; } } if (fixedTestcases.size() > 0) { // Add fixed test cases to summary. summary += "

The following test cases were fixed in the latest test run:
"; List sortedFixedTestcases = new ArrayList<>(fixedTestcases); sortedFixedTestcases.sort(Comparator.naturalOrder()); for (String testcaseName : sortedFixedTestcases) { summary += "- " + testcaseName + "
"; } } if (transientTestcaseFailures.size() > 0) { // Add transient test case failures to summary. summary += "

The following transient test case failures occured:
"; List sortedTransientTestcaseFailures = new ArrayList<>(transientTestcaseFailures); sortedTransientTestcaseFailures.sort(Comparator.naturalOrder()); for (String testcaseName : sortedTransientTestcaseFailures) { summary += "- " + testcaseName + "
"; } } if (skippedTestcaseFailures.size() > 0) { // Add skipped test case failures to summary. summary += "

The following test cases have not been run since failing:
"; List sortedSkippedTestcaseFailures = new ArrayList<>(skippedTestcaseFailures); sortedSkippedTestcaseFailures.sort(Comparator.naturalOrder()); for (String testcaseName : sortedSkippedTestcaseFailures) { summary += "- " + testcaseName + "
"; } } if (acknowledgedFailures.size() > 0) { // Add acknowledged test case failures to summary. List sortedAcknowledgedFailures = new ArrayList<>(acknowledgedFailures); sortedAcknowledgedFailures.sort(Comparator.naturalOrder()); if (acknowledgedFailures.size() > 0) { summary += "

The following acknowledged test case failures continued to fail:
"; for (String testcaseName : sortedAcknowledgedFailures) { summary += "- " + testcaseName + "
"; } } } String testName = mostRecentRun.getKey().getParent().getName(); String uploadDateString = TimeUtil.getDateString(mostRecentRun.getStartTimestamp()); String subject = "VTS Test Alert: " + testName + " @ " + uploadDateString; if (newTestcaseFailures.size() > 0) { String body = "Hello,

New test case failure(s) in " + testName + " for device build ID(s): " + buildId + ".

" + summary + footer; try { messages.add(EmailHelper.composeEmail(emailAddresses, subject, body)); } catch (MessagingException | UnsupportedEncodingException e) { logger.log(Level.WARNING, "Error composing email : ", e); } } else if (continuedTestcaseFailures.size() > 0) { String body = "Hello,

Continuous test case failure(s) in " + testName + " for device build ID(s): " + buildId + ".

" + summary + footer; try { messages.add(EmailHelper.composeEmail(emailAddresses, subject, body)); } catch (MessagingException | UnsupportedEncodingException e) { logger.log(Level.WARNING, "Error composing email : ", e); } } else if (transientTestcaseFailures.size() > 0) { String body = "Hello,

Transient test case failure(s) in " + testName + " but tests all " + "are passing in the latest device build(s): " + buildId + ".

" + summary + footer; try { messages.add(EmailHelper.composeEmail(emailAddresses, subject, body)); } catch (MessagingException | UnsupportedEncodingException e) { logger.log(Level.WARNING, "Error composing email : ", e); } } else if (fixedTestcases.size() > 0) { String body = "Hello,

All test cases passed in " + testName + " for device build ID(s): " + buildId + "!

" + summary + footer; try { messages.add(EmailHelper.composeEmail(emailAddresses, subject, body)); } catch (MessagingException | UnsupportedEncodingException e) { logger.log(Level.WARNING, "Error composing email : ", e); } } return new TestStatusEntity( testName, mostRecentRun.getStartTimestamp(), passingTestcaseCount, failingTestCases.size(), failingTestCases); } /** * Add a task to process test run data * * @param testRunKey The key of the test run whose data process. */ public static void addTask(Key testRunKey) { Queue queue = QueueFactory.getDefaultQueue(); String keyString = KeyFactory.keyToString(testRunKey); queue.add( TaskOptions.Builder.withUrl(ALERT_JOB_URL) .param("runKey", keyString) .method(TaskOptions.Method.POST)); } @Override public void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException { DatastoreService datastore = DatastoreServiceFactory.getDatastoreService(); String runKeyString = request.getParameter("runKey"); Key testRunKey; try { testRunKey = KeyFactory.stringToKey(runKeyString); } catch (IllegalArgumentException e) { logger.log(Level.WARNING, "Invalid key specified: " + runKeyString); return; } String testName = testRunKey.getParent().getName(); TestStatusEntity status = null; Key statusKey = KeyFactory.createKey(TestStatusEntity.KIND, testName); try { status = TestStatusEntity.fromEntity(datastore.get(statusKey)); } catch (EntityNotFoundException e) { // no existing status } if (status == null) { status = new TestStatusEntity(testName); } if (status.getUpdatedTimestamp() >= testRunKey.getId()) { // Another job has already updated the status first return; } List emails = EmailHelper.getSubscriberEmails(testRunKey.getParent()); StringBuffer fullUrl = request.getRequestURL(); String baseUrl = fullUrl.substring(0, fullUrl.indexOf(request.getRequestURI())); String link = baseUrl + "/show_tree?testName=" + testName + "&endTime=" + testRunKey.getId(); List messageQueue = new ArrayList<>(); Map failedTestcaseMap = getCurrentFailures(status); List testAcks = getTestCaseAcknowledgments(testRunKey.getParent()); List testRuns = getTestRuns( testRunKey.getParent(), status.getUpdatedTimestamp(), testRunKey.getId()); if (testRuns.size() == 0) return; TestStatusEntity newStatus = getTestStatus(testRuns, link, failedTestcaseMap, testAcks, emails, messageQueue); if (newStatus == null) { // No changes to status return; } int retries = 0; while (true) { Transaction txn = datastore.beginTransaction(); try { try { status = TestStatusEntity.fromEntity(datastore.get(statusKey)); } catch (EntityNotFoundException e) { // no status left } if (status == null || status.getUpdatedTimestamp() >= newStatus.getUpdatedTimestamp()) { txn.rollback(); } else { // This update is most recent. datastore.put(newStatus.toEntity()); txn.commit(); EmailHelper.sendAll(messageQueue); } break; } catch (ConcurrentModificationException | DatastoreFailureException | DatastoreTimeoutException e) { logger.log(Level.WARNING, "Retrying alert job insert: " + statusKey); if (retries++ >= DatastoreHelper.MAX_WRITE_RETRIES) { logger.log(Level.SEVERE, "Exceeded alert job retries: " + statusKey); throw e; } } finally { if (txn.isActive()) { txn.rollback(); } } } } }