| /* |
| * Copyright (C) 2017 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.compatibility.common.tradefed.targetprep; |
| |
| import com.android.compatibility.common.tradefed.build.CompatibilityBuildHelper; |
| import com.android.compatibility.common.tradefed.util.DynamicConfigFileReader; |
| import com.android.compatibility.common.util.BusinessLogic; |
| import com.android.compatibility.common.util.BusinessLogicFactory; |
| import com.android.compatibility.common.util.FeatureUtil; |
| import com.android.compatibility.common.util.PropertyUtil; |
| 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.device.ITestDevice; |
| import com.android.tradefed.log.LogUtil.CLog; |
| import com.android.tradefed.targetprep.BuildError; |
| import com.android.tradefed.targetprep.ITargetCleaner; |
| import com.android.tradefed.targetprep.TargetSetupError; |
| import com.android.tradefed.testtype.suite.TestSuiteInfo; |
| import com.android.tradefed.util.FileUtil; |
| import com.android.tradefed.util.MultiMap; |
| import com.android.tradefed.util.RunUtil; |
| import com.android.tradefed.util.StreamUtil; |
| import com.android.tradefed.util.net.HttpHelper; |
| import com.android.tradefed.util.net.IHttpHelper; |
| |
| import com.google.api.client.auth.oauth2.Credential; |
| import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; |
| import com.google.common.annotations.VisibleForTesting; |
| import com.google.common.base.Strings; |
| |
| import org.json.JSONException; |
| import org.json.JSONObject; |
| import org.xmlpull.v1.XmlPullParserException; |
| |
| import java.io.File; |
| import java.io.FileInputStream; |
| import java.io.FileNotFoundException; |
| import java.io.IOException; |
| import java.io.DataOutputStream; |
| import java.net.HttpURLConnection; |
| import java.net.URL; |
| import java.util.ArrayList; |
| import java.util.Collections; |
| import java.util.Date; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Set; |
| |
| /** |
| * Pushes business Logic to the host and the test device, for use by test cases in the test suite. |
| */ |
| @OptionClass(alias="business-logic-preparer") |
| public class BusinessLogicPreparer implements ITargetCleaner { |
| |
| /* Placeholder in the service URL for the suite to be configured */ |
| private static final String SUITE_PLACEHOLDER = "{suite-name}"; |
| |
| /* String for creating files to store the business logic configuration on the host */ |
| private static final String FILE_LOCATION = "business-logic"; |
| /* String for creating cached business logic configuration files */ |
| private static final String BL_CACHE_FILE = "business-logic-cache"; |
| /* Number of days for which cached business logic is valid */ |
| private static final int BL_CACHE_DAYS = 5; |
| /* BL_CACHE_DAYS converted to millis */ |
| private static final long BL_CACHE_MILLIS = BL_CACHE_DAYS * 1000 * 60 * 60 * 24L; |
| /* Extension of business logic files */ |
| private static final String FILE_EXT = ".bl"; |
| /* Default amount of time to attempt connection to the business logic service, in seconds */ |
| private static final int DEFAULT_CONNECTION_TIME = 60; |
| /* Time to wait between connection attempts to the business logic service, in millis */ |
| private static final long SLEEP_BETWEEN_CONNECTIONS_MS = 5000; // 5 seconds |
| /* Dynamic config constants */ |
| private static final String DYNAMIC_CONFIG_FEATURES_KEY = "business_logic_device_features"; |
| private static final String DYNAMIC_CONFIG_PROPERTIES_KEY = "business_logic_device_properties"; |
| private static final String DYNAMIC_CONFIG_PACKAGES_KEY = "business_logic_device_packages"; |
| private static final String DYNAMIC_CONFIG_EXTENDED_DEVICE_INFO_KEY = |
| "business_logic_extended_device_info"; |
| private static final String DYNAMIC_CONFIG_CONDITIONAL_TESTS_ENABLED_KEY = |
| "conditional_business_logic_tests_enabled"; |
| /* Format used to append the enabled attribute to the serialized business logic string. */ |
| private static final String ENABLED_ATTRIBUTE_SNIPPET = ", \"%s\":%s }"; |
| |
| @Option(name = "business-logic-url", description = "The URL to use when accessing the " + |
| "business logic service, parameters not included", mandatory = true) |
| private String mUrl; |
| |
| @Option(name = "business-logic-api-key", description = "The API key to use when accessing " + |
| "the business logic service.", mandatory = true) |
| private String mApiKey; |
| |
| @Option(name = "business-logic-api-scope", description = "The URI of api scope to use when " + |
| "retrieving business logic rules.") |
| /* URI of api scope to use when retrieving business logic rules */ |
| private String mApiScope; |
| |
| @Option(name = "cache-business-logic", description = "Whether to keep and use cached " + |
| "business logic files.") |
| private boolean mCache = false; |
| |
| @Option(name = "clean-cache-business-logic", description = "Like option " + |
| "'cache-business-logic', but forces a refresh of the cached business logic file") |
| private boolean mCleanCache = false; |
| |
| @Option(name = "ignore-business-logic-failure", description = "Whether to proceed with the " + |
| "suite invocation if retrieval of business logic fails.") |
| private boolean mIgnoreFailure = false; |
| |
| @Option(name="conditional-business-logic-tests-enabled", |
| description="Setting to true will ensure the device specific tests are executed.") |
| private boolean mConditionalTestsEnabled = false; |
| |
| @Option(name = "business-logic-connection-time", description = "Amount of time to attempt " + |
| "connection to the business logic service, in seconds.") |
| private int mMaxConnectionTime = DEFAULT_CONNECTION_TIME; |
| |
| private String mDeviceFilePushed; |
| private String mHostFilePushed; |
| |
| /** |
| * {@inheritDoc} |
| */ |
| @Override |
| public void setUp(ITestDevice device, IBuildInfo buildInfo) throws TargetSetupError, BuildError, |
| DeviceNotAvailableException { |
| String requestParams = buildRequestParams(device, buildInfo); |
| String baseUrl = mUrl.replace(SUITE_PLACEHOLDER, getSuiteName()); |
| String businessLogicString = null; |
| // use cached business logic string if options are set accordingly and cache is valid, |
| // otherwise proceed with remote download. |
| if (!shouldReadCache() |
| || (businessLogicString = readFromCache(baseUrl, requestParams)) == null) { |
| CLog.i("Attempting to connect to business logic service..."); |
| } |
| long start = System.currentTimeMillis(); |
| while (businessLogicString == null |
| && System.currentTimeMillis() < (start + (mMaxConnectionTime * 1000))) { |
| try { |
| businessLogicString = doPost(baseUrl, requestParams); |
| businessLogicString = addRuntimeConfig(businessLogicString, buildInfo); |
| } catch (IOException e) { |
| // ignore, re-attempt connection with remaining time |
| CLog.d("BusinessLogic connection failure message: %s\nRetrying...", e.getMessage()); |
| RunUtil.getDefault().sleep(SLEEP_BETWEEN_CONNECTIONS_MS); |
| } |
| } |
| if (businessLogicString == null) { |
| if (mIgnoreFailure) { |
| CLog.e("Failed to connect to business logic service.\nProceeding with test " |
| + "invocation, tests depending on the remote configuration will fail.\n"); |
| return; |
| } else { |
| throw new TargetSetupError(String.format("Cannot connect to business logic " |
| + "service for suite %s.\nIf this problem persists, re-invoking with " |
| + "option '--ignore-business-logic-failure' will cause tests to execute " |
| + "anyways (though tests depending on the remote configuration will fail).", |
| TestSuiteInfo.getInstance().getName()), device.getDeviceDescriptor()); |
| } |
| } |
| |
| if (shouldWriteCache()) { |
| writeToCache(businessLogicString, baseUrl, requestParams, mCleanCache); |
| } |
| // Push business logic string to host file |
| try { |
| File hostFile = FileUtil.createTempFile(FILE_LOCATION, FILE_EXT); |
| FileUtil.writeToFile(businessLogicString, hostFile); |
| mHostFilePushed = hostFile.getAbsolutePath(); |
| CompatibilityBuildHelper buildHelper = new CompatibilityBuildHelper(buildInfo); |
| buildHelper.setBusinessLogicHostFile(hostFile); |
| } catch (IOException e) { |
| throw new TargetSetupError(String.format( |
| "Retrieved business logic for suite %s could not be written to host", |
| TestSuiteInfo.getInstance().getName()), device.getDeviceDescriptor()); |
| } |
| // Push business logic string to device file |
| removeDeviceFile(device); // remove any existing business logic file from device |
| if (device.pushString(businessLogicString, BusinessLogic.DEVICE_FILE)) { |
| mDeviceFilePushed = BusinessLogic.DEVICE_FILE; |
| } else { |
| throw new TargetSetupError(String.format( |
| "Retrieved business logic for suite %s could not be written to device %s", |
| TestSuiteInfo.getInstance().getName(), device.getSerialNumber()), |
| device.getDeviceDescriptor()); |
| } |
| } |
| |
| /** Helper to populate the business logic service request with info about the device. */ |
| @VisibleForTesting |
| String buildRequestParams(ITestDevice device, IBuildInfo buildInfo) |
| throws DeviceNotAvailableException { |
| CompatibilityBuildHelper buildHelper = new CompatibilityBuildHelper(buildInfo); |
| MultiMap<String, String> paramMap = new MultiMap<>(); |
| paramMap.put("suite_version", buildHelper.getSuiteVersion()); |
| paramMap.put("oem", String.valueOf(PropertyUtil.getManufacturer(device))); |
| String accessToken = getToken(); |
| // Add api key (not authenticated) or Oath token, but not both. |
| if (Strings.isNullOrEmpty(accessToken)) { |
| paramMap.put("key", mApiKey); |
| } else { |
| paramMap.put("access_token", accessToken); |
| } |
| for (String feature : getBusinessLogicFeatures(device, buildInfo)) { |
| paramMap.put("features", feature); |
| } |
| for (String property : getBusinessLogicProperties(device, buildInfo)) { |
| paramMap.put("properties", property); |
| } |
| for (String pkg : getBusinessLogicPackages(device, buildInfo)) { |
| paramMap.put("packages", pkg); |
| } |
| for (String deviceInfo : getExtendedDeviceInfo(buildInfo)) { |
| paramMap.put("device_info", deviceInfo); |
| } |
| IHttpHelper helper = new HttpHelper(); |
| String paramString = helper.buildParameters(paramMap); |
| CLog.d("Built param string: \"%s\"", paramString); |
| return paramString; |
| } |
| |
| @VisibleForTesting |
| String getSuiteName() { |
| return TestSuiteInfo.getInstance().getName().toLowerCase(); |
| } |
| |
| /* Get device properties list, with element format "<property_name>:<property_value>" */ |
| private List<String> getBusinessLogicProperties(ITestDevice device, IBuildInfo buildInfo) |
| throws DeviceNotAvailableException { |
| List<String> properties = new ArrayList<>(); |
| Map<String, String> clientIds = PropertyUtil.getClientIds(device); |
| for (Map.Entry<String, String> id : clientIds.entrySet()) { |
| // add client IDs to the list of properties |
| properties.add(String.format("%s:%s", id.getKey(), id.getValue())); |
| } |
| |
| try { |
| List<String> propertyNames = DynamicConfigFileReader.getValuesFromConfig(buildInfo, |
| getSuiteName(), DYNAMIC_CONFIG_PROPERTIES_KEY); |
| for (String name : propertyNames) { |
| // Use String.valueOf in case property is undefined for the device ("null") |
| String value = String.valueOf(device.getProperty(name)); |
| properties.add(String.format("%s:%s", name, value)); |
| } |
| } catch (XmlPullParserException | IOException e) { |
| CLog.e("Failed to pull business logic properties from dynamic config"); |
| } |
| return properties; |
| } |
| |
| /* Get device features list */ |
| private List<String> getBusinessLogicFeatures(ITestDevice device, IBuildInfo buildInfo) |
| throws DeviceNotAvailableException { |
| try { |
| List<String> dynamicConfigFeatures = DynamicConfigFileReader.getValuesFromConfig( |
| buildInfo, getSuiteName(), DYNAMIC_CONFIG_FEATURES_KEY); |
| Set<String> deviceFeatures = FeatureUtil.getAllFeatures(device); |
| dynamicConfigFeatures.retainAll(deviceFeatures); |
| return dynamicConfigFeatures; |
| } catch (XmlPullParserException | IOException e) { |
| CLog.e("Failed to pull business logic features from dynamic config"); |
| return new ArrayList<>(); |
| } |
| } |
| |
| /* Get device packages list */ |
| private List<String> getBusinessLogicPackages(ITestDevice device, IBuildInfo buildInfo) |
| throws DeviceNotAvailableException { |
| try { |
| List<String> dynamicConfigPackages = DynamicConfigFileReader.getValuesFromConfig( |
| buildInfo, getSuiteName(), DYNAMIC_CONFIG_PACKAGES_KEY); |
| Set<String> devicePackages = device.getInstalledPackageNames(); |
| dynamicConfigPackages.retainAll(devicePackages); |
| return dynamicConfigPackages; |
| } catch (XmlPullParserException | IOException e) { |
| CLog.e("Failed to pull business logic packages from dynamic config"); |
| return new ArrayList<>(); |
| } |
| } |
| |
| /* Get extended device info*/ |
| private List<String> getExtendedDeviceInfo(IBuildInfo buildInfo) { |
| List<String> extendedDeviceInfo = new ArrayList<>(); |
| File deviceInfoPath = buildInfo.getFile(DeviceInfoCollector.DEVICE_INFO_DIR); |
| if (deviceInfoPath == null || !deviceInfoPath.exists()) { |
| CLog.w("Device Info directory was not created (Make sure you are not running plan " + |
| "\"*ts-dev\" or including option -d/--skip-device-info)"); |
| return extendedDeviceInfo; |
| } |
| List<String> requiredDeviceInfo = null; |
| try { |
| requiredDeviceInfo = DynamicConfigFileReader.getValuesFromConfig( |
| buildInfo, getSuiteName(), DYNAMIC_CONFIG_EXTENDED_DEVICE_INFO_KEY); |
| } catch (XmlPullParserException | IOException e) { |
| CLog.e("Failed to pull business logic Extended DeviceInfo from dynamic config. " |
| + "Error: %s", e); |
| return extendedDeviceInfo; |
| } |
| File ediFile = null; |
| try{ |
| for (String ediEntry: requiredDeviceInfo) { |
| String[] fileAndKey = ediEntry.split(":"); |
| ediFile = FileUtil |
| .findFile(deviceInfoPath, fileAndKey[0] + ".deviceinfo.json"); |
| String jsonString = FileUtil.readStringFromFile(ediFile); |
| JSONObject jsonObj = new JSONObject(jsonString); |
| String value = jsonObj.getString(fileAndKey[1]); |
| extendedDeviceInfo |
| .add(String.format("%s:%s:%s", fileAndKey[0], fileAndKey[1], value)); |
| } |
| }catch(JSONException | IOException e){ |
| CLog.e("Failed to read or parse Extended DeviceInfo JSON file: %s. Error: %s", |
| ediFile.getAbsolutePath(), e); |
| return new ArrayList<>(); |
| } |
| return extendedDeviceInfo; |
| } |
| |
| private boolean shouldReadCache() { |
| return mCache && !mCleanCache; |
| } |
| |
| private boolean shouldWriteCache() { |
| return mCache || mCleanCache; |
| } |
| |
| /** |
| * Append runtime configuration attributes to the end of the Json string. |
| * Determine if conditional tests should execute and add the value to the serialized business |
| * logic settings. |
| */ |
| private String addRuntimeConfig(String businessLogicString, IBuildInfo buildInfo) { |
| int indexOfClosingParen = businessLogicString.lastIndexOf("}"); |
| // Replace the closing paren with th enabled flag and closing paren. ex |
| // { "a":4 } -> {"a":4, "enabled":true } |
| return businessLogicString.substring(0, indexOfClosingParen) + |
| String.format(ENABLED_ATTRIBUTE_SNIPPET, |
| BusinessLogicFactory.CONDITIONAL_TESTS_ENABLED, |
| shouldExecuteConditionalTests(buildInfo)); |
| } |
| |
| /** |
| * Execute device specific test if enabled in config or through the command line. |
| * Otherwise skip all conditional tests. |
| */ |
| private boolean shouldExecuteConditionalTests(IBuildInfo buildInfo) { |
| boolean enabledInConfig = false; |
| try { |
| String enabledInConfigValue = DynamicConfigFileReader.getValueFromConfig( |
| buildInfo, getSuiteName(), DYNAMIC_CONFIG_CONDITIONAL_TESTS_ENABLED_KEY); |
| enabledInConfig = Boolean.parseBoolean(enabledInConfigValue); |
| } catch (XmlPullParserException | IOException e) { |
| CLog.e("Failed to pull business logic features from dynamic config"); |
| } |
| return enabledInConfig || mConditionalTestsEnabled; |
| } |
| |
| /** |
| * Read the string from the business logic cache, handling the following cases with a null |
| * return value: |
| * - The cached file does not exist |
| * - The cached file cannot be read |
| * - The cached file is timestamped more than BL_CACHE_DAYS prior to now |
| * In the last two cases, the file is deleted so an up-to-date configuration may be cached anew |
| */ |
| private static synchronized String readFromCache(String baseUrl, String params) { |
| // baseUrl + params hashCode makes file unique, in case host runs invocations for different |
| // device builds and/or test suites using business logic |
| File cachedFile = getCachedFile(baseUrl, params); |
| if (!cachedFile.exists()) { |
| CLog.i("No cached business logic found"); |
| return null; |
| } |
| try { |
| BusinessLogic cachedLogic = BusinessLogicFactory.createFromFile(cachedFile); |
| Date cachedDate = cachedLogic.getTimestamp(); |
| if (System.currentTimeMillis() - cachedDate.getTime() < BL_CACHE_MILLIS) { |
| CLog.i("Using cached business logic from: %s", cachedDate.toString()); |
| return FileUtil.readStringFromFile(cachedFile); |
| } else { |
| CLog.i("Cached business logic out-of-date, deleting cached file"); |
| FileUtil.deleteFile(cachedFile); |
| } |
| } catch (IOException e) { |
| CLog.w("Failed to read cached business logic, deleting cached file"); |
| FileUtil.deleteFile(cachedFile); |
| } |
| return null; |
| } |
| |
| /** |
| * Write a string retrieved from the business logic service to the cache file, only if the |
| * file does not already exist. Synchronize this method to prevent concurrent writes in the |
| * sharding case. |
| * @param blString the string to cache |
| * @param baseUrl the base business logic request url containing suite info |
| * @param params the string of params for the business logic request containing device info |
| */ |
| private static synchronized void writeToCache(String blString, String baseUrl, String params, |
| boolean overwrite) { |
| // baseUrl + params hashCode makes file unique, in case host runs invocations for different |
| // device builds and/or test suites using business logic |
| File cachedFile = getCachedFile(baseUrl, params); |
| if (!cachedFile.exists() || overwrite) { |
| // don't overwrite existing file, whether from previous shard or previous invocation |
| try { |
| FileUtil.writeToFile(blString, cachedFile); |
| } catch (IOException e) { |
| throw new RuntimeException("Failed to write business logic to cache file", e); |
| } |
| } |
| } |
| |
| /** |
| * Get the cached business logic file given the base url and params used to retrieve this logic. |
| */ |
| private static File getCachedFile(String baseUrl, String params) { |
| int hashCode = (baseUrl + params).hashCode(); |
| return new File(System.getProperty("java.io.tmpdir"), BL_CACHE_FILE + hashCode); |
| } |
| |
| private String doPost(String baseUrl, String params) throws IOException { |
| URL url = new URL(baseUrl); |
| HttpURLConnection conn = (HttpURLConnection) url.openConnection(); |
| conn.setRequestMethod("POST"); |
| conn.setRequestProperty("User-Agent", "BusinessLogicClient"); |
| // Send params in POST request body |
| conn.setDoOutput(true); |
| try (DataOutputStream wr = new DataOutputStream(conn.getOutputStream())) { |
| wr.writeBytes(params); |
| } |
| int responseCode = conn.getResponseCode(); |
| CLog.d("Business Logic Service Response Code : %s", responseCode); |
| return StreamUtil.getStringFromStream(conn.getInputStream()); |
| } |
| |
| /** |
| * {@inheritDoc} |
| */ |
| @Override |
| public void tearDown(ITestDevice device, IBuildInfo buildInfo, Throwable e) |
| throws DeviceNotAvailableException { |
| // Clean up existing host and device files unconditionally |
| if (mHostFilePushed != null) { |
| FileUtil.deleteFile(new File(mHostFilePushed)); |
| } |
| if (mDeviceFilePushed != null && !(e instanceof DeviceNotAvailableException)) { |
| removeDeviceFile(device); |
| } |
| } |
| |
| /** Remove business logic file from the device */ |
| private static void removeDeviceFile(ITestDevice device) throws DeviceNotAvailableException { |
| device.executeShellCommand(String.format("rm -rf %s", BusinessLogic.DEVICE_FILE)); |
| } |
| |
| /* |
| * Returns an OAuth2 token string obtained using a service account json key file. |
| * |
| * Uses the service account key file location stored in environment variable 'APE_API_KEY' |
| * to request an OAuth2 token. |
| */ |
| private String getToken() { |
| String keyFilePath = System.getenv("APE_API_KEY"); |
| if (Strings.isNullOrEmpty(keyFilePath)) { |
| CLog.d("Environment variable APE_API_KEY not set."); |
| return null; |
| } |
| if (Strings.isNullOrEmpty(mApiScope)) { |
| CLog.d("API scope not set, use flag --business-logic-api-scope."); |
| return null; |
| } |
| try { |
| Credential credential = GoogleCredential.fromStream(new FileInputStream(keyFilePath)) |
| .createScoped(Collections.singleton(mApiScope)); |
| credential.refreshToken(); |
| return credential.getAccessToken(); |
| } catch (FileNotFoundException e) { |
| CLog.e(String.format("Service key file %s doesn't exist.", keyFilePath)); |
| } catch (IOException e) { |
| CLog.e(String.format("Can't read the service key file, %s", keyFilePath)); |
| } |
| return null; |
| } |
| } |